实现选择不同比例进行图片的裁剪(canvas实现)

133 阅读9分钟

前言

相信大家做的后台管理平台基本都会有 上传图片 的功能,有的上传的图片用于前台展示,如果不进行任何处理的话,就很有可能在前台展示的时候会出现 被压缩 的情况,当然如果对图片展示不严格的话,可以使用 css object-fit 属性,也可以对图片进行对应的裁剪,但是严格的话就需要后台上传的时候进行 裁剪。文本裁剪 可以选择 不同比例 或者 自定义 裁剪。

实现

图解

图示以横图为示例

image.png

分析

怎么才能裁剪图片呢?

熟悉 canvas 的朋友们,应该知道 drawImage 方法,可以进行图片绘制。

// 可以点击 drawImage 方法查看文档,文档详细描述了这些参数代表的意思
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

接下来就是需要知道如何得到这些参数的值?

首先我们能拿到上传后的 file 图片文件,我们需要知道 原图尺寸,可以通过 new Image() 创建 img 元素,从而可以知道 原图尺寸,然后就可以得到一系列我们需要的参数。

前提知识:查看 FileReader

const reader = new FileReader()

reader.onload = (e) => {
  // 创建 img 元素,是为了知道原图的尺寸
  const img = new Image()
  img.src = e.target.result
  
  // 当图片成功加载时的回调函数
  img.onload = () => {
    this.image = e.target.result
    // 需要放在 $nextTick中,等待 image 加载完成后执行
    this.$nextTick(() => {
      // 知道图片原始尺寸,从而判断是横图还是竖图,计算裁剪框的最大裁剪尺寸以及偏移量
      this.handleImageDimensions(img)
    })
  }
}

// 将文件读取为DataURL(base64编码的字符串)
reader.readAsDataURL(file) 

handleImageDimensions 方法的实现

// 判断是横图还是竖图
const isHorizontal = img.width > img.height

// 计算图片的宽高比,用于确定最大裁剪尺寸
const ratio = isHorizontal ? img.height / img.width : img.width / img.height

// 根据容器尺寸和宽高比计算最大裁剪尺寸
const maxCropSize = ratio * this.imageContainerSize

this.isHorizontal = isHorizontal

// 设置裁剪框到的尺寸
this.sizeStyles = isHorizontal
? { width: this.imageContainerSize + 'px', height: maxCropSize + 'px' }
: { height: this.imageContainerSize + 'px', width: maxCropSize + 'px' }

this.maxCropSize = maxCropSize

// 设置裁剪框的偏移量,使其在图片中位置
this.cropX = isHorizontal ? (this.imageContainerSize - maxCropSize) / 2 : 0
this.cropY = isHorizontal ? 0 : (this.imageContainerSize - maxCropSize) / 2

// 裁剪图片
this.cropImage()

至此,我们已经得到了所有所需参数的值,可以使用 canvas 进行裁剪

const img = new Image()
img.src = this.image
img.onload = () => {
    const croppedCanvas = document.createElement('canvas')
    const croppedCtx = croppedCanvas.getContext('2d')

    // 设置画布的尺寸
    croppedCanvas.width = this.cropWidth
    croppedCanvas.height = this.cropHeight

    // 缩放比例
    let imgScale = 1

    // 得到原图与裁剪框的缩放比例
    if (this.isHorizontal) {
      imgScale = Math.min(
        img.width / this.imageContainerSize,
        img.height / this.maxCropSize
      )
    } else {
      imgScale = Math.min(
        img.width / this.maxCropSize,
        img.height / this.imageContainerSize
      )
    }


    // 从原图中裁剪出指定区域
    croppedCtx.drawImage(
      img,
      this.cropX * imgScale,
      this.cropY * imgScale,
      this.cropWidth * imgScale,
      this.cropHeight * imgScale,
      0,
      0,
      this.cropWidth,
      this.cropHeight
    )

    // 获取裁剪后的图片数据
    this.croppedImage = croppedCanvas.toDataURL()
}

现在已经实现了图片的裁剪功能,但是会发现裁剪出来的图片会失真,这是为什么呢?原因是我们裁剪出来的图片很小,但是在大的画布上展示,图片被放大了,肯定就会失真,解决也非常简单,把上面的代码稍微改动一下就可以了

// 改动以下两个地方即可
croppedCanvas.width = this.cropWidth * imgScale
croppedCanvas.height = this.cropHeight * imgScale

croppedCtx.drawImage(
  img,
  this.cropX * imgScale,
  this.cropY * imgScale,
  this.cropWidth * imgScale,
  this.cropHeight * imgScale,
  0,
  0,
  this.cropWidth * imgScale,
  this.cropHeight * imgScale
)

对比一下就会很明显,裁剪的越小就会越明显

image.png

至此,裁剪图片的功能就已经完成,但是光裁剪可不行,不能裁剪手动指定的区域以及不能缩放裁剪框,所以接下来就是实现裁剪框拖拽以及缩放

实现拖动裁剪框

// 监听鼠标按下裁剪框的事件,可以得到鼠标按下的坐标 startX 以及 startY

// 开启鼠标移动事件,可以得到鼠标移动的距离
const dx = event.clientX - this.startX
const dy = event.clientY - this.startY

this.cropX += dx // 裁剪框的横向偏移量
this.cropY += dy // 裁剪框的纵向偏移量

// 处理边界情况,不能拖拽出图片的区域
// 处理左边界以及上边界
if (this.cropX < 0) this.cropX = 0
if (this.cropY < 0) this.cropY = 0

// 处理右边界以及下边界
if (this.isHorizontal) {
  if (this.cropX + this.cropWidth > this.imageContainerSize) {
    this.cropX = this.imageContainerSize - this.cropWidth
  }
  if (this.cropY + this.cropHeight > this.maxCropSize) {
    this.cropY = this.maxCropSize - this.cropHeight
  }
} else {
  if (this.cropX + this.cropWidth > this.maxCropSize) {
    this.cropX = this.maxCropSize - this.cropWidth
  }
  if (this.cropY + this.cropHeight > this.imageContainerSize) {
    this.cropY = this.imageContainerSize - this.cropHeight
  }
}

实现裁剪框缩放

// 监听鼠标按下等比例缩放按钮的事件,可以得到鼠标按下的坐标 startX 以及 startY

// 然后开启鼠标移动事件,可以得到鼠标移动的距离
const dx = event.clientX - this.startX
const dy = event.clientY - this.startY

// 设置裁剪框的大小
this.cropWidth += dx
this.cropHeight += dx

// 限制裁剪区域的最小尺寸
if (this.cropWidth < 50) this.cropWidth = 50
if (this.cropHeight < 50) this.cropHeight = 50

// 限制裁剪框不超出图片范围
// prop.length !== 0 是为了解决自定义裁剪框的边界问题,为 0 表示是自定义裁剪,就表示只需要设置宽或高,不用等比例缩放
if (this.isHorizontal) {
  // 横图的情况下,宽度不能超过容器宽度,高度不能超过图片高度
  if (this.cropX + this.cropWidth > this.imageContainerSize) {
    this.cropWidth = this.imageContainerSize - this.cropX
    if (prop.length !== 0) {
      this.cropHeight = this.cropWidth * (prop[1] / prop[0])
    }
  }
  if (this.cropY + this.cropHeight > this.maxCropSize) {
    this.cropHeight = this.maxCropSize - this.cropY
    if (prop.length !== 0) {
      this.cropWidth = this.cropHeight * (prop[0] / prop[1])
    }
  }
} else {
  // 竖图的情况下,宽度不能超过图片宽度,高度不能超过容器高度
  if (this.cropX + this.cropWidth > this.maxCropSize) {
    this.cropWidth = this.maxCropSize - this.cropX
    if (prop.length !== 0) {
      this.cropHeight = this.cropWidth * (prop[1] / prop[0])
    }
  }
  if (this.cropY + this.cropHeight > this.imageContainerSize) {
    this.cropHeight = this.imageContainerSize - this.cropY
    if (prop.length !== 0) {
      this.cropWidth = this.cropHeight * (prop[0] / prop[1])
    }
  }
}

每次切换比例时,需要重新计算裁剪框的尺寸以及位置

// 修改裁剪框的尺寸
switch (this.aspectRatio) {
  case '1/1':
    this.cropWidth = this.cropHeight = this.maxCropSize // 1:1
    break
  case '4/3':
    this.cropWidth = this.isHorizontal ? this.maxCropSize * (4 / 3) : this.maxCropSize
    this.cropHeight = this.isHorizontal ? this.maxCropSize : this.maxCropSize * (3 / 4)
    break
  case '16/9':
    this.cropWidth = this.isHorizontal ? this.maxCropSize * (16 / 9) : this.maxCropSize
    this.cropHeight = this.isHorizontal ? this.maxCropSize : this.maxCropSize * (9 / 16)
    break
  case 'custom':
    this.cropHeight = this.cropWidth = this.maxCropSize / 2
    break
}

// 修改裁剪框的位置
this.cropX = this.isHorizontal ? (this.imageContainerSize - this.cropWidth) / 2 : 0
this.cropY = this.isHorizontal ? 0 : (this.imageContainerSize - this.cropHeight) / 2

上文有很多地方是可以优化,比如:判断逻辑、硬编码、层次结构等等,是为了更加通俗易懂,也可以对照下方优化后的代码进行阅读

优化后完整代码

<template>
  <div class="avatar-cropper">
    <div class="upload-section">
      <input type="file" @change="onFileChange" accept="image/*" />
    </div>

    <div v-if="image" class="cropper-section">
      <div class="image-container">
        <div class="image-box" :style="sizeStyles">
          <img :src="image" class="uploaded-image" :style="sizeStyles" />
          <div
            class="crop-area"
            :style="{
              width: cropWidth + 'px',
              height: cropHeight + 'px',
              left: cropX + 'px',
              top: cropY + 'px'
            }"
            @mousedown="startCrop"
          >
            <div class="resize-handle" @mousedown.stop="startResize"></div>
          </div>
        </div>
      </div>

      <div class="controls">
        <label for="aspect-ratio">选择裁剪比例:</label>
        <select v-model="aspectRatio" @change="updateCropDimensions">
          <option value="1/1">1:1</option>
          <option value="4/3">4:3</option>
          <option value="16/9">16:9</option>
          <option value="custom">自定义</option>
        </select>
      </div>
    </div>

    <div v-if="croppedImage" class="result-section">
      <h3 class="result-title">裁剪结果:</h3>
      <div class="preview-image">
        <img :src="croppedImage" class="result-image" />
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      image: null,
      croppedImage: null,
      imageContainerSize: 300,
      cropX: 0, // 裁剪区域的X坐标
      cropY: 0, // 裁剪区域的Y坐标
      cropWidth: 0, // 裁剪区域的宽度
      cropHeight: 0, // 裁剪区域的高度
      aspectRatio: '1/1', // 裁剪比例
      maxCropSize: 0, // 最大裁剪尺寸
      isDragging: false,
      isResizing: false,
      startX: 0,
      startY: 0,
      sizeStyles: {}, // 图片容器的样式
      isHorizontal: false // 是否为横向图片
    }
  },
  methods: {
    // 文件选择事件处理函数
    onFileChange(event) {
      // 获取用户选择的第一个文件
      const file = event.target.files[0]
      // 如果有文件被选择
      if (file) {
        // 创建一个FileReader对象来读取文件
        const reader = new FileReader()
        // 当文件被成功读取时的回调函数
        reader.onload = (e) => {
          // 创建一个Image对象来加载读取的文件数据
          const img = new Image()
          // 设置Image对象的src属性为读取的文件数据
          img.src = e.target.result
          // 当图片成功加载时的回调函数
          img.onload = () => {
            this.image = e.target.result
            // 使用$nextTick等待图片渲染完成后处理图片尺寸
            this.$nextTick(() => {
              // 调用处理图片尺寸的方法
              this.handleImageDimensions(img)
            })
          }
        }
        // 使用FileReader对象的readAsDataURL方法读取文件
        reader.readAsDataURL(file)
      }
    },
    // 处理图片尺寸,确定裁剪尺寸和裁剪区域位置
    handleImageDimensions(img) {
      // 检查图片尺寸是否为零,因为这将导致无法进行裁剪操作
      if (img.width === 0 || img.height === 0) {
        console.error('Image dimension is zero, cannot proceed.')
        return
      }

      // 判断图片是横向还是纵向,以便后续计算裁剪尺寸
      const isHorizontal = img.width > img.height

      // 计算图片的宽高比,用于确定最大裁剪尺寸
      const ratio = isHorizontal ? img.height / img.width : img.width / img.height

      // 根据容器尺寸和宽高比计算最大裁剪尺寸
      const maxCropSize = ratio * this.imageContainerSize

      // 更新组件的状态,指示图片是横向还是纵向
      this.isHorizontal = isHorizontal

      // 根据图片方向设置尺寸样式,确保裁剪区域正确显示
      this.sizeStyles = isHorizontal
        ? { width: this.imageContainerSize + 'px', height: maxCropSize + 'px' }
        : { height: this.imageContainerSize + 'px', width: maxCropSize + 'px' }

      // 保存最大裁剪尺寸以供后续使用
      this.maxCropSize = maxCropSize

      // 更新裁剪维度,确保裁剪区域与图片方向和尺寸匹配
      this.updateCropDimensions()

      // 根据图片方向计算裁剪区域的X和Y坐标,以中心裁剪的方式定位裁剪区域
      this.cropX = isHorizontal ? (this.imageContainerSize - maxCropSize) / 2 : 0
      this.cropY = isHorizontal ? 0 : (this.imageContainerSize - maxCropSize) / 2

      // 执行裁剪操作
      this.cropImage()
    },
    // 更新裁剪区域的尺寸
    updateCropDimensions() {
      // 调用calculateDimensions方法计算裁剪区域的宽度和高度
      const { width: cropWidth, height: cropHeight } = this.calculateDimensions(
        this.aspectRatio,
        this.maxCropSize,
        this.isHorizontal
      )
      // 更新裁剪区域的宽度和高度
      this.cropWidth = cropWidth
      this.cropHeight = cropHeight

      // 根据图像容器的尺寸和裁剪区域的尺寸,计算裁剪区域的X轴和Y轴位置
      this.cropX = this.isHorizontal ? (this.imageContainerSize - cropWidth) / 2 : 0
      this.cropY = this.isHorizontal ? 0 : (this.imageContainerSize - cropHeight) / 2
    },
    // 计算裁剪区域的尺寸
    calculateDimensions(aspectRatio, maxCropSize, isHorizontal) {
      // 根据不同的纵横比(aspectRatio),计算裁剪区域的宽度和高度
      switch (aspectRatio) {
        case '1/1':
          // 对于1:1的纵横比,宽度和高度都等于最大裁剪尺寸
          return { width: maxCropSize, height: maxCropSize }
        case '4/3':
          // 对于4:3的纵横比,根据是否是水平方向(isHorizontal)来决定宽度和高度
          return isHorizontal
            ? { width: maxCropSize * (4 / 3), height: maxCropSize }
            : { width: maxCropSize, height: maxCropSize * (3 / 4) }
        case '16/9':
          // 对于16:9的纵横比,同样根据是否是水平方向来决定宽度和高度
          return isHorizontal
            ? { width: maxCropSize * (16 / 9), height: maxCropSize }
            : { width: maxCropSize, height: maxCropSize * (9 / 16) }
        case 'custom':
          // 对于自定义的纵横比,宽度和高度都取最大裁剪尺寸的一半
          return { width: maxCropSize / 2, height: maxCropSize / 2 }
        default:
          // 如果传入了无效的纵横比,抛出错误
          throw new Error('无效的 aspectRatio')
      }
    },
    // 裁剪图片
    cropImage() {
      const img = new Image()
      img.src = this.image
      img.onload = () => {
        const croppedCanvas = document.createElement('canvas')
        const croppedCtx = croppedCanvas.getContext('2d')

        let imgScale = 1

        // 计算图片的缩放比例
        if (this.isHorizontal) {
          imgScale = Math.min(
            img.width / this.imageContainerSize,
            img.height / this.maxCropSize
          )
        } else {
          imgScale = Math.min(
            img.width / this.maxCropSize,
            img.height / this.imageContainerSize
          )
        }
        
        // 设置裁剪画布的大小!!!一定要设置剩余比例!!!画布大小也要剩余比例!!!否则裁剪的图片大小是对的,但是缩放到的比例不对,会导致裁剪图片失真
        croppedCanvas.width = this.cropWidth * imgScale
        croppedCanvas.height = this.cropHeight * imgScale

        // 计算裁剪区域在原图中的位置
        const scaledCropX = this.cropX * imgScale
        const scaledCropY = this.cropY * imgScale

        // 从原图中裁剪出指定区域
        croppedCtx.drawImage(
          img,
          scaledCropX,
          scaledCropY,
          this.cropWidth * imgScale,
          this.cropHeight * imgScale,
          0,
          0,
          this.cropWidth * imgScale,
          this.cropHeight * imgScale
        )

        // 获取裁剪后的图片数据
        this.croppedImage = croppedCanvas.toDataURL()
      }
    },
    startCrop(event) {
      this.isDragging = true
      this.startX = event.clientX
      this.startY = event.clientY
      document.addEventListener('mousemove', this.onMouseMove)
      document.addEventListener('mouseup', this.stopCrop)
    },
    // 鼠标移动事件处理函数
    onMouseMove(event) {
      // 计算鼠标相对于起始位置的偏移量
      const dx = event.clientX - this.startX
      const dy = event.clientY - this.startY

      // 根据当前操作状态更新裁剪区域的位置或大小
      if (this.isDragging) {
        this.updateCropPosition(dx, dy)
      } else if (this.isResizing) {
        this.updateCropSize(dx, dy)
      }

      // 更新起始位置为当前鼠标位置,为下一次移动做准备
      this.startX = event.clientX
      this.startY = event.clientY
    },
    // 更新裁剪区域的位置
    updateCropPosition(dx, dy) {
      // 根据鼠标偏移量更新裁剪区域的位置
      this.cropX += dx
      this.cropY += dy

      // 确保裁剪区域的位置在图像容器范围内
      this.cropX = this.isHorizontal
        ? Math.max(0, Math.min(this.cropX, this.imageContainerSize - this.cropWidth))
        : Math.max(0, Math.min(this.cropX, this.maxCropSize - this.cropWidth))
      this.cropY = this.isHorizontal
        ? Math.max(0, Math.min(this.cropY, this.maxCropSize - this.cropHeight))
        : Math.max(0, Math.min(this.cropY, this.imageContainerSize - this.cropHeight))
    },
    // 更新裁剪区域的大小
    updateCropSize(dx, dy) {
      const minCropSize = 50
      let prop = []
      // 根据不同的纵横比更新裁剪区域的大小
      switch (this.aspectRatio) {
        case '1/1':
          this.cropWidth += dx
          this.cropHeight += dx
          prop = [1, 1]
          break
        case '4/3':
          this.cropWidth += dx
          this.cropHeight = this.cropWidth * (3 / 4)
          prop = [4, 3]
          break
        case '16/9':
          this.cropWidth += dx
          this.cropHeight = this.cropWidth * (9 / 16)
          prop = [16, 9]
          break
        default:
          this.cropWidth += dx
          this.cropHeight += dy
          break
      }

      // 确保裁剪区域的大小不超过图像容器的限制
      this.cropWidth = this.isHorizontal
        ? Math.min(this.cropWidth, this.imageContainerSize - this.cropX)
        : Math.min(this.cropWidth, this.maxCropSize - this.cropX)
      this.cropHeight = this.isHorizontal
        ? Math.min(this.cropHeight, this.maxCropSize - this.cropY)
        : Math.min(this.cropHeight, this.imageContainerSize - this.cropY)

      // 计算裁剪区域的最大大小
      const maxCropWidthSize = this.isHorizontal ? this.imageContainerSize : this.maxCropSize
      const maxCropHeightSize = this.isHorizontal ? this.maxCropSize : this.imageContainerSize

      // 如果有指定纵横比,确保裁剪区域不会超出图像容器或者不会小于minCropSize
      if (prop.length !== 0) {
        if (this.cropX + this.cropWidth >= maxCropWidthSize) {
          this.cropWidth = maxCropWidthSize - this.cropX
          this.cropHeight = this.cropWidth * (prop[1] / prop[0])
        }
        if (this.cropWidth < minCropSize) {
          this.cropWidth = minCropSize
          this.cropHeight = this.cropWidth * (prop[1] / prop[0])
        }
        if (this.cropY + this.cropHeight >= maxCropHeightSize) {
          this.cropHeight = maxCropHeightSize - this.cropY
          this.cropWidth = this.cropHeight * (prop[0] / prop[1])
        }
        if (this.cropHeight < minCropSize) {
          this.cropHeight = minCropSize
          this.cropWidth = this.cropHeight * (prop[0] / prop[1])
        }
      } else {
        // 确保自定义时,裁剪区域的最小大小
        this.cropWidth = Math.max(minCropSize, this.cropWidth)
        this.cropHeight = Math.max(minCropSize, this.cropHeight)
      }
    },
    stopCrop() {
      this.isDragging = false
      this.isResizing = false
      this.cropImage()
      document.removeEventListener('mousemove', this.onMouseMove)
      document.removeEventListener('mouseup', this.stopCrop)
    },
    startResize(event) {
      this.isResizing = true
      this.startX = event.clientX
      this.startY = event.clientY

      document.addEventListener('mousemove', this.onMouseMove)
      document.addEventListener('mouseup', this.stopCrop)
    }
  }
}
</script>

<style lang="scss" scoped>
.avatar-cropper {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20px;
  background-color: #f5f5f5;
}

.upload-section {
  margin-bottom: 20px;
}

.cropper-section {
  margin-bottom: 20px;
  position: relative;
}

.image-container {
  position: relative;
  width: 300px;
  height: 300px;
  overflow: hidden;
  border: 1px dashed #ccc;
  background-color: #000;
  .image-box {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    .uploaded-image {
      position: absolute;
    }

    .crop-area {
      position: absolute;
      border: 2px dashed #007bff;
      background-color: rgba(0, 123, 255, 0.3);
      cursor: move;
      .resize-handle {
        position: absolute;
        width: 10px;
        height: 10px;
        background-color: #007bff;
        right: -5px;
        bottom: -5px;
        cursor: nwse-resize;
      }
    }
  }
}

.controls {
  margin-top: 10px;
}

.result-section {
  margin-top: 20px;
  .result-title {
    font-size: 18px;
    font-weight: bold;
    margin-bottom: 10px;
  }
  .preview-image {
    width: 100px;
    height: 100px;
    .result-image {
      width: 100%;
      border: 1px solid #ccc;
    }
  }
}
</style>

结语

至此,关于图片裁剪的功能也全部完工了