基于fabric实现图片裁剪功能

1,363 阅读13分钟

万字预警!

前言

这篇文章主要是教你怎么在画布上基于fabric去实现一个图片裁剪的功能,分别讲述了 clip 和 crop 两种方式

如何使用fabric实现图片裁剪功能?

因为我们是在一个新的画布上去对图片进行裁剪,并且需要考虑到后续的一些拓展操作,所以我们需要重新初始化canvas,加载图片数据,再对图片进行操作,简单来说就以下几点:

  1. 重新设置canvas标签,重新初始化fabric的画布,背景色需要设置为裁剪常见的格子
  2. 加载图片到canvas
  3. 添加裁剪区域,裁剪区域应该是绘制的矩形,并且自定义了两个控制按钮
  4. 实现裁剪功能

初始化fabric

<canvas id="canvas" width="800" height="600"></canvas>
const canvas = new fabric.Canvas('canvas');

如何绘制格子背景色

为什么要绘制格子背景色? 因为我们这个操作是需要对图片进行处理,所以我们需要有一个对比度较高的背景,避免纯色背景导致的看不清图片操作的可能性

image.png

代码

    background-image: repeating-conic-gradient(rgb(204, 204, 204) 0deg, rgb(204, 204, 204) 25%, rgb(255, 255, 255) 0deg, rgb(255, 255, 255) 50%);
    background-position: 0px 0px, 0.5rem 0.5rem;
    background-size: 0.21rem 0.21rem;

加载图片到 Canvas

fabric.Image.fromURL('path/to/your/image.jpg', function(img) {
    canvas.add(img);
});

如何设置图片到画布中央

为了更方便操作,所以我们在加载图片进来的时候最好默认加载的区域是画布中间

  fabric.Image.fromURL(props.imgData, function (img) {
        const objCenter = imgEditor.canvas.getCenterPoint()
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        const width = fabric.util.parseUnitInOrMm(img.get('width') / fabric.DPI, 'mm')
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        const height = fabric.util.parseUnitInOrMm(img.get('height') / fabric.DPI, 'mm')
        img.scaleToHeight(height)
        img.scaleToWidth(width)
        img.set({
          left: objCenter.x,
          top: objCenter.y,
          originX: 'center', // 设置图像的原点为中心
          originY: 'center',
          fill: '',
          stroke: '',
          strokeWidth: 0
        })
        img.setCoords()
        imgEditor.canvas.discardActiveObject()
        imgEditor.canvas.setActiveObject(img)
        imgEditor.canvas.add(img)
      })

添加裁剪区域及蒙层

可以使用 Fabric.js 提供的矩形工具来创建一个裁剪区域,并使其可拖动和调整大小

如何判断点击区域是否在操作区域及操作框内

因为我们是通过鼠标事件拖拽绘制的裁剪区域,所以我们需要判断鼠标点击区域是否在裁剪的元素、裁剪元素的操作框以及自定义操作区域内,如果在内的话则不需要重写绘制裁剪区域,而是选中当前裁剪区域进行操作 而且因为容易误操作,所以我们在判断的时候最好还要给上一个 offset 的判断偏差值

获取鼠标位置
    // 获取鼠标位置
    const pointer = this.canvas.getPointer(mouseEvent.e)
检查鼠标落点是否在对象内
   const offset = 10 // 偏差值

    // 检查对象本身
    if (
      obj.containsPoint({ x: pointer.x - offset, y: pointer.y }) ||
      obj.containsPoint({ x: pointer.x + offset, y: pointer.y }) ||
      obj.containsPoint({ x: pointer.x, y: pointer.y - offset }) ||
      obj.containsPoint({ x: pointer.x, y: pointer.y + offset })
    ) {
      return true
    }
检查鼠标落点是否在控制框内
    // 检查控制框
    const aCoords = obj.aCoords
    if (
      pointer.x >= aCoords.tl.x - offset &&
      pointer.x <= aCoords.tr.x + offset &&
      pointer.y >= aCoords.tl.y - offset &&
      pointer.y <= aCoords.bl.y + offset
    ) {
      return true
    }
检查鼠标落点是否在自定义操作区内
const controls = obj.controls
    for (const controlKey in controls) {
      const control = controls[controlKey]
      const checkMap = ['ok', 'cancel']
      if (checkMap.includes(control.actionName)) {
        const controlSize = 24 // 控制按钮的大小为24像素
        const controlX = obj.left + obj.width + control.offsetX - controlSize / 2
        const controlY = obj.top + control.offsetY - controlSize / 2

        const offset = 0

        // 检查点击点是否在控制按钮的范围内
        if (
          pointer.x >= controlX - offset &&
          pointer.x <= controlX + controlSize + offset &&
          pointer.y >= controlY - offset &&
          pointer.y <= controlY + controlSize + offset
        ) {
          return true
        }
      }
    }
完整实现
  checkMouseDownIsInObject(mouseEvent: fabric.IEvent, obj) {
    if (!obj) return false
    // 获取鼠标位置
    const pointer = this.owlCanvas.getPointer(mouseEvent.e)

    const offset = 10 // 偏差值

    // 检查对象本身
    if (
      obj.containsPoint({ x: pointer.x - offset, y: pointer.y }) ||
      obj.containsPoint({ x: pointer.x + offset, y: pointer.y }) ||
      obj.containsPoint({ x: pointer.x, y: pointer.y - offset }) ||
      obj.containsPoint({ x: pointer.x, y: pointer.y + offset })
    ) {
      return true
    }

    // 检查控制框
    const aCoords = obj.aCoords
    if (
      pointer.x >= aCoords.tl.x - offset &&
      pointer.x <= aCoords.tr.x + offset &&
      pointer.y >= aCoords.tl.y - offset &&
      pointer.y <= aCoords.bl.y + offset
    ) {
      return true
    }

    const controls = obj.controls
    for (const controlKey in controls) {
      const control = controls[controlKey]
      const checkMap = ['ok', 'cancel']
      if (checkMap.includes(control.actionName)) {
        const controlSize = 24 // 控制按钮的大小为24像素
        const controlX = obj.left + obj.width + control.offsetX - controlSize / 2
        const controlY = obj.top + control.offsetY - controlSize / 2

        const offset = 0

        // 检查点击点是否在控制按钮的范围内
        if (
          pointer.x >= controlX - offset &&
          pointer.x <= controlX + controlSize + offset &&
          pointer.y >= controlY - offset &&
          pointer.y <= controlY + controlSize + offset
        ) {
          return true
        }
      }
    }

    return false
  }

如何添加自定义操作按钮

创建一个新的class方便自定义
import { fabric } from 'fabric'

const cropPath = fabric.util.createClass(fabric.Path, {
  type: 'cropPath',
  isStatic: false,
  initialize(option: any) {
    option = option || {}
    this.callSuper('initialize', option)
  },
  
cropPath.fromObject = (options: any, callback: (obj: any) => any) => {
  fabric.Object._fromObject(
    'path',
    options,
    (newObj) => {
      //const { fill, strokeWidth, stroke, markData } = options
      newObj.set({
        ...options
      })
      callback(newObj)
    },
    'path'
  )
}

// @ts-ignore wait-doing
fabric.cropPath = cropPath

export default cropPath

自定义操作按钮渲染逻辑
import checkLineSvg from '../../assets/svg/check-line.svg'
import closeLineSvg from '../../assets/svg/close-line.svg'

const img = document.createElement('img')
img.src = checkLineSvg

const img2 = document.createElement('img')
img2.src = closeLineSvg

const customCursorStyleHandler = () => {
  return 'pointer'
}

const renderIcon = (ctx, left, top, _img) => {
  const size = 24
  ctx.save()
  ctx.translate(left, top)
  ctx.drawImage(_img, -size / 2, -size / 2, size, size)
  ctx.restore()
}

const renderOkIcon = (ctx, left, top) => {
  renderIcon(ctx, left, top, img)
}

const renderCancelIcon = (ctx, left, top) => {
  renderIcon(ctx, left, top, img2)
}
初始化时添加渲染处理
initialize(option: any) {
    option = option || {}
    this.callSuper('initialize', option)
    // 设置控制按钮的默认行为处理函数
    this.controls = {
      ...fabric.Object.prototype.controls,
      ok: new fabric.Control({
        x: 0.5,
        y: -0.5,
        offsetX: -45,
        offsetY: -25,
        actionName: 'ok',
        cursorStyleHandler: customCursorStyleHandler,
        render: renderOkIcon,
        withConnection: false
      }),
      cancel: new fabric.Control({
        x: 0.5,
        y: -0.5,
        offsetX: -12,
        offsetY: -25,
        actionName: 'cancel',
        cursorStyleHandler: customCursorStyleHandler,
        render: renderCancelIcon,
        withConnection: false
      })
    }
    delete this.controls.mtr // 移除默认的缩放控制按钮
  },
添加鼠标按下时的点击事件
  setControlAction(params: {
    okAction: (eventData, target) => void
    cancelAction: (eventData, target) => void
  }) {
    if (params.okAction) {
      this.controls.ok.mouseDownHandler = (eventData, target) => {
        params.okAction(eventData, target) // 在 actionHandler 中调用传入的方法
        return true
      }
    }
    if (params.cancelAction) {
      this.controls.cancel.mouseDownHandler = (eventData, target) => {
        params.cancelAction(eventData, target) // 在 actionHandler 中调用传入的方法
        return true
      }
    }
    this.canvas && this.canvas.renderAll() // 重新渲染 Canvas
  },
调用方式
this.rectPath?.setControlAction({
      okAction: () => {
        console.log('ok')
        this.cropImage()
      },
      cancelAction: () => {
        this.removeCropRect()
      }
})

如何添加裁剪区域

onMousedown

在鼠标按下时,我们需要做一些操作来保证我们可以进行绘制

  1. 判断当前落点是否在操作区域内,如果不在则需要移除原有的裁剪区域和蒙层后重新设置绘制属性以及初始落点位置
  const isInObject = this.checkMouseDownIsInObject(event, this.rectPath)
    if (this.rectPath && !isInObject) {
      this.removeCropRect()
    }
  1. 为了避免误操作,如果 getActiveObject 能拿到值则不做处理
 if (this.owlCanvas.getActiveObject()) return
  1. 否则的话就记录当前开始绘制,并且记录开始的点的位置
 this.startPoint = this.owlCanvas.getPointer(event.e)
 this.isDraw = true
onMousemove

在鼠标移动的过程中,如果我们是在绘制的状态则需要实时记录当前的结束点位,并且为了保证视觉上的连贯性,我们需要一直触发 render() 方法

 override onMousemove(event: fabric.IEvent) {
    if (this.isDraw) {
      this.endPoint = this.canvas.getPointer(event.e)
      this.render()
    }
  }
onMouseup
设置结束绘制时的焦点状态

如果在鼠标抬起时属于绘制的状态,并且开始点位不是结束点位的话,我们就需要将当前绘制出来的裁剪区域聚焦处理

this.endPoint = this.canvas.getPointer(event.e)
if (this.isDraw && this.startPoint.toString() != this.endPoint.toString()) {
      this.render(true)
      this.canvas.setActiveObject(this.rectPath as fabric.Object)
      this.rectPath!.selectable = true
      this.rectPath!.hasControls = true
      ;(this.rectPath!.hoverCursor acs any) = this.cursor
      this.hasControl = true

      this.canvas.selection = true
      this.canvas.hoverCursor = ''
    }
添加蒙层处理

这点我们在下面再详细说明

如何添加蒙层

image.png

蒙层本质上的目的为了让用户更关注裁剪区域内容,我们这里期望的样式当然是除了裁剪区域外的区域都加上蒙层,不过说实话,单纯用canvas画出这个效果还挺麻烦的,花了我整整一个下午时间来调试这一个功能 我差点都放弃这个方案准备用四个灰色矩形拼成裁剪区域外的蒙层了

对于这个功能其实一般就分为以下几点:

判断在绘制蒙层时是否存在旧蒙层,如果存在则去掉
     // 移除旧的遮罩矩形
      if (this.overlay) {
        this.canvas.remove(this.overlay)
      }
创建大的遮罩矩形

这里是创建最大位置的遮罩矩形,它的大小刚好可以覆盖整个canvas操作区域

  // 创建大的遮罩矩形
  const maskRect = new fabric.Rect({
        left: 0,
        top: 0,
        width: this.canvas.width,
        height: this.canvas.height,
        fill: 'rgba(0,0,0,0.5)', // 半透明灰色
        selectable: false,
        evented: false
  })

计算裁剪区域外的路径

这里计算的是除了裁剪区域外的所有区域的路径,为的是方便裁剪出来

  // 计算裁剪区域外的路径
 const clipPathData = this.calculateOuterClipPath()

 calculateOuterClipPath(): string {
    // 获取 this.rectPath 的位置和大小
    const left = this.rectPath!.left || 0
    const top = this.rectPath!.top || 0
    const width = (this.rectPath!.width || 0) * (this.rectPath!.scaleX || 1)
    const height = (this.rectPath!.height || 0) * (this.rectPath!.scaleY || 1)

    // 创建裁剪区域外的路径
    const outerClipPath = [
      `M 0 0`,
      `L ${this.owlCanvas.width} 0`,
      `L ${this.owlCanvas.width} ${this.owlCanvas.height}`,
      `L 0 ${this.owlCanvas.height}`,
      `Z`,
      `M ${left} ${top}`,
      `L ${left} ${top + height}`,
      `L ${left + width} ${top + height}`,
      `L ${left + width} ${top}`,
      `Z`
    ]

    return outerClipPath.join(' ')
  }
创建裁剪路径对象并应用裁剪路径
// 创建裁剪路径对象
const clipPath = new fabric.Path(clipPathData, {
    fill: 'white',
    absolutePositioned: true
})

// 应用裁剪路径
maskRect.set({
   clipPath: clipPath
})
添加新的遮罩矩形,并且添加裁剪区域变动时的监控
this.overlay = maskRect
this.canvas.add(this.overlay)

this.rectPath?.on('moving', this.updateOverlay.bind(this))
this.rectPath?.on('scaling', this.updateOverlay.bind(this))

this.canvas.renderAll()


  updateOverlay() {
    if (this.overlay) {
      // 计算裁剪区域外的路径
      const clipPathData = this.calculateOuterClipPath()
      // 更新裁剪路径对象
      const clipPath = new fabric.Path(clipPathData, {
        fill: 'white',
        absolutePositioned: true
      })
      // 应用裁剪路径
      this.overlay!.set({
        clipPath: clipPath
      })

      this.overlay!.setCoords()
      this.canvas.renderAll()
    }
  }

完整实现
override onMouseup(event: fabric.IEvent) {
    this.endPoint = this.canvas.getPointer(event.e)
    if (this.isDraw && this.startPoint.toString() != this.endPoint.toString()) {
      this.render(true)
      this.canvas.setActiveObject(this.rectPath as fabric.Object)
      this.rectPath!.selectable = true
      this.rectPath!.hasControls = true
      ;(this.rectPath!.hoverCursor as any) = this.cursor
      this.hasControl = true

      this.canvas.selection = true
      this.canvas.hoverCursor = ''
      // 移除旧的遮罩矩形
      if (this.overlay) {
        this.canvas.remove(this.overlay)
      }

      // 创建大的遮罩矩形
      const maskRect = new fabric.Rect({
        left: 0,
        top: 0,
        width: this.canvas.width,
        height: this.canvas.height,
        fill: 'rgba(0,0,0,0.5)', // 半透明灰色
        selectable: false,
        evented: false
      })

      // 计算裁剪区域外的路径
      const clipPathData = this.calculateOuterClipPath()

      // 创建裁剪路径对象
      const clipPath = new fabric.Path(clipPathData, {
        fill: 'white',
        absolutePositioned: true
      })

      // 应用裁剪路径
      maskRect.set({
        clipPath: clipPath
      })

      // 添加新的遮罩矩形
      this.overlay = maskRect
      this.canvas.add(this.overlay)

      this.rectPath?.on('moving', this.updateOverlay.bind(this))
      this.rectPath?.on('scaling', this.updateOverlay.bind(this))

      this.canvas.renderAll()
    }
    this.isDraw = false
  }

定义渲染方法

  render(check: boolean = false) {
    if (this.rectPath) this.canvas.remove(this.rectPath)

    this.rectPath = new ImgCropRect(
      this.formatToPath([
        this.startPoint!,
        new fabric.Point(this.endPoint!.x, this.startPoint!.y),
        this.endPoint!,
        new fabric.Point(this.startPoint!.x, this.endPoint!.y)
      ])
    )
    this.rectPath?.set({
      ...this.toolConfig,
      strokeWidth: this.toolConfig.strokeWidth! / this.owlCanvas.getZoom(),
      fill: 'transparent',
      selectable: this.hasControl,
      hasControls: this.hasControl,
      lockMovementX: false,
      lockMovementY: false
    })
    // @ts-ignore
    this.rectPath?.setControlAction({
      okAction: () => {
        console.log('ok')
        this.cropImage()
      },
      cancelAction: () => {
        this.removeCropRect()
      }
    })

    if (check) this.setId(this.rectPath)
    this.canvas.add(this.rectPath!)
    this.rectPath!.setCoords()
    this.canvas.renderAll()
  }

处理裁剪逻辑

裁剪方式 - clip

对于clip的方式我们可以直接使用fabric的clippath来进行处理,实现的方式很简单

cropImage() {
  if (this.rectPath) {
    const { left, top, width, height } = this.rectPath.getBoundingRect()

    const objects = this.canvas.getObjects()
    let img: any = null
    // 移除图片元素
    for (const i in objects) {
      if (objects[i].type === 'image') {
        img = objects[i]
        break
      }
    }

    if (img && img.type === 'image') {
      // // 计算裁剪矩形相对于图片的坐标和大小
      const rectLeft = left - img.left!
      const rectTop = top - img.top!
      const rectWidth = width / img.scaleX!
      const rectHeight = height / img.scaleY!

      // 创建裁剪路径
      const clipPath = new fabric.Rect({
        left: rectLeft,
        top: rectTop,
        width: rectWidth,
        height: rectHeight
      })

      // 设置图片的裁剪路径
      img.set({
        clipPath: clipPath
      })
      // 清除裁剪矩形对象
      this.removeCropRect()

      // 渲染画布
      this.canvas.renderAll()
    }
  }
}

实现效果是这样的

clip.gif

我们可以从实现效果中看到,实际上只是显示区域的裁剪,并没有实际改变到图片的数据 这个与我们的需求不符,所以我们需要用crop的方式来进行裁剪

裁剪方式 - crop

crop 的方式相对来说就比较复杂了
fabric中并没有对这方面的支持,但是没关系
我们可以知道fabric本质上是一个基于canvas的框架
那么我们可以用最原始的方式来进行图片的裁剪
大致的实现流程应该是跟下面差不多的

计算裁剪矩形相对于图片的坐标和大小
  const rectLeft = Math.min(left, img.left)
  const rectTop = Math.min(top, img.top)
  const rectWidth = width / img.scaleX!
  const rectHeight = height / img.scaleY!
创建裁剪后的 Canvas 元素
 // 创建裁剪后的 Canvas 元素
  const cropCanvas = document.createElement('canvas')
  const cropCtx = cropCanvas.getContext('2d')
计算裁剪参数,将图片数据绘制到 Canvas 上进行裁剪
        const startLeft = img.left > left ? 0 : img.left - left
        const startTop = img.top > top ? 0 : img.top - top

        // 将图片数据绘制到 Canvas 上进行裁剪, 比较复杂
        cropCtx!.drawImage(
          img.getElement(),
          rectLeft, // 裁剪区域在原始图片中的 x 坐标
          rectTop, // 裁剪区域在原始图片中的 y 坐标
          rectWidth, // 裁剪区域的宽度
          rectHeight, // 裁剪区域的高度
          startLeft, // 在目标 Canvas 中绘制的 x 坐标(通常设置为裁剪区域的左上角)
          startTop, // 在目标 Canvas 中绘制的 y 坐标(通常设置为裁剪区域的左上角)
          rectWidth, // 在目标 Canvas 中绘制的宽度(通常与裁剪区域宽度相同)
          rectHeight // 在目标 Canvas 中绘制的高度(通常与裁剪区域高度相同)
        )
创建裁剪后的 Fabric.Image 对象
 fabric.Image.fromURL(cropCanvas.toDataURL(), (croppedImg) => {

})
添加裁剪后的结果图片
      // 创建裁剪后的 Fabric.Image 对象
        fabric.Image.fromURL(cropCanvas.toDataURL(), (croppedImg) => {
          // 设置裁剪后图片的位置和尺寸
          croppedImg.set({
            left: left,
            top: top,
            scaleX: img.scaleX,
            scaleY: img.scaleY
          })

          // 替换原始图片对象
          this.canvas.remove(img)
          this.canvas.add(croppedImg)
          this.canvas.setActiveObject(croppedImg)

          // 清除裁剪矩形对象
          this.removeCropRect()

          // 渲染画布
          this.canvas.renderAll()
        })

但是我们发现 crop 出来后的裁剪数据实际上是有问题的,所以,我们得好好研究一下crop的数据该怎么计算

如何计算 crop 裁剪数据

drawImage参数解释
基本语法
context.drawImage(image, dx, dy);
context.drawImage(image, dx, dy, dWidth, dHeight);
context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

我们

参数解释
  • image
    • 必需,要绘制到画布上的图像。可以是以下类型之一:
      • HTMLImageElement:使用 元素创建的图像对象。
      • SVGImageElement:使用 元素创建的 SVG 图像对象。
      • HTMLVideoElement:使用 元素创建的视频对象。
      • HTMLCanvasElement:可以将另一个 元素的内容作为图像。
      • ImageBitmap:通过 createImageBitmap 方法创建的位图对象。
      • ImageData:代表 的像素数据。
  • sx, sy, sWidth, sHeight
    • 可选,指定在图像中要绘制的源矩形区域的位置和尺寸。
    • sx, sy 是源矩形区域的左上角坐标。
    • sWidth, sHeight 是源矩形区域的宽度和高度。如果不指定,默认为整个图像的大小。
  • dx, dy
    • 指定目标画布上绘制图像的位置。
    • dx, dy 是目标矩形区域的左上角坐标。
  • dWidth, dHeight
    • 可选,指定在目标画布上绘制图像的尺寸大小。如果指定,图像将被缩放或拉伸以适应这个尺寸。
使用示例
  • 只指定图像和目标位置:
context.drawImage(image, dx, dy);

这会将图像绘制在画布上的指定位置 (dx, dy),使用图像的原始大小。

  • 指定图像、源区域和目标位置:
context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

这会从源图像的 (sx, sy) 开始,以 (sWidth, sHeight) 的大小绘制到目标画布的 (dx, dy) 处,并且可以调整到 (dWidth, dHeight) 的大小。

注意事项
  • 如果指定了源区域 (sx, sy, sWidth, sHeight),但未指定目标尺寸 (dWidth, dHeight),则源区域会被拉伸或缩小以适应目标尺寸。
  • 如果未指定源区域 (sx, sy, sWidth, sHeight),默认绘制整个图像。
  • 如果未指定目标尺寸 (dWidth, dHeight),默认使用源区域的大小或整个图像的大小。
计算裁剪区域在原始图片中的坐标

image.png 我们可以从图上大致可以看到可能会出现的裁剪的部分情况,那么我们就要结合 drawImage 的入参来计算出合适的参数了

获取图片真实坐标

因为在fabric里,元素的真实坐标并不只取决于 left 、 top两个属性,而是由** left、top、pathOffset、ownMatrixCache **决定,如果我们直接使用 left 、 top 来进行运算,得出的肯定是错误的结果

     const imgTopLeft = img.getPointByOrigin('left', 'top')
     const imgLeft = imgTopLeft.x
     const imgTop = imgTopLeft.y
计算裁剪矩形相对于图片的坐标和大小
        const rectLeft = (left - imgLeft) / img.scaleX!
        const rectTop = (top - imgTop) / img.scaleY!
        const rectWidth = width / img.scaleX!
        const rectHeight = height / img.scaleY!
最终的 drawImage 参数
  cropCtx!.drawImage(
          img.getElement(),
          rectLeft, // 裁剪区域在原始图片中的 x 坐标
          rectTop, // 裁剪区域在原始图片中的 y 坐标
          rectWidth, // 裁剪区域的宽度
          rectHeight, // 裁剪区域的高度
          0, // 在目标 Canvas 中绘制的 x 坐标(目标 Canvas 的左上角)
          0, // 在目标 Canvas 中绘制的 y 坐标(目标 Canvas 的左上角)
          width, // 在目标 Canvas 中绘制的宽度(裁剪区域的宽度)
          height // 在目标 Canvas 中绘制的高度(裁剪区域的高度)
 )
最终的裁剪方法实现
 cropImage() {
    if (this.rectPath) {
      const { left, top, width, height } = this.rectPath.getBoundingRect()

      const objects = this.canvas.getObjects()
      let img: any = null
      // 移除图片元素
      for (const i in objects) {
        if (objects[i].type === 'image') {
          img = objects[i]
          break
        }
      }

      if (img && img.type === 'image') {
        // 获取图片的真实坐标
        const imgTopLeft = img.getPointByOrigin('left', 'top')
        const imgLeft = imgTopLeft.x
        const imgTop = imgTopLeft.y

        // 计算裁剪矩形相对于图片的坐标和大小
        const rectLeft = (left - imgLeft) / img.scaleX!
        const rectTop = (top - imgTop) / img.scaleY!
        const rectWidth = width / img.scaleX!
        const rectHeight = height / img.scaleY!

        // 创建裁剪后的 Canvas 元素
        const cropCanvas = document.createElement('canvas')
        const cropCtx = cropCanvas.getContext('2d')

        // 设置 Canvas 尺寸与裁剪区域相同
        cropCanvas.width = width
        cropCanvas.height = height

        // 将图片数据绘制到 Canvas 上进行裁剪
        cropCtx!.drawImage(
          img.getElement(),
          rectLeft, // 裁剪区域在原始图片中的 x 坐标
          rectTop, // 裁剪区域在原始图片中的 y 坐标
          rectWidth, // 裁剪区域的宽度
          rectHeight, // 裁剪区域的高度
          0, // 在目标 Canvas 中绘制的 x 坐标(目标 Canvas 的左上角)
          0, // 在目标 Canvas 中绘制的 y 坐标(目标 Canvas 的左上角)
          width, // 在目标 Canvas 中绘制的宽度(裁剪区域的宽度)
          height // 在目标 Canvas 中绘制的高度(裁剪区域的高度)
        )

        // 创建裁剪后的 Fabric.Image 对象
        fabric.Image.fromURL(cropCanvas.toDataURL(), (croppedImg) => {
          // 设置裁剪后图片的位置和尺寸
          croppedImg.set({
            left: left,
            top: top,
            scaleX: img.scaleX,
            scaleY: img.scaleY
          })

          // 替换原始图片对象
          this.canvas.remove(img)
          this.canvas.add(croppedImg)
          this.canvas.setActiveObject(croppedImg)

          // 清除裁剪矩形对象
          this.removeCropRect()

          // 渲染画布
          this.canvas.renderAll()
        })
      }
    }
  }

最终实现效果

图片裁剪.gif