前端实现自定义图片裁剪组件及使用指南

本文主要实现了一种在前端通过canvas rect等基础元素,实现图片的自由裁剪的功能组件,技术栈是React + tailwindcss,无其他第三方组件的引入。

在线的demo示例使用可以参考:图片在线裁剪拼图工具

💡 ImageCropper组件源码在最后,可以自由获取!!!

先看实际的效果图:

  • 裁剪前:
    在这里插入图片描述
  • 裁剪后:
    在这里插入图片描述

📖 组件介绍

ImageCropper 是一个基于 HTML5 Canvas 的图片裁剪组件,支持 8 个控制点精确调整裁剪区域,提供丰富的交互功能和高质量输出。

主要特性

  • 8 个控制点:四个角和四条边的中点,支持精确调整
  • 多种操作:拖拽移动、缩放、旋转、重置
  • 移动端支持:完美适配触摸操作
  • 高质量输出:支持原始分辨率裁剪,保持图片清晰度
  • 独立组件:仅依赖 React,易于集成

📦 安装与导入

方式一:直接使用组件文件

ImageCropper.jsx 文件复制到你的项目中:

import ImageCropper from './components/ImageCropper'

方式二:作为 npm 包(需要发布)

npm install image-cropper
import ImageCropper from 'image-cropper'

🔧 Props 接口

属性名 类型 必填 说明
imageSrc string 图片源,支持 URL 或 DataURL
isOpen boolean 控制裁剪弹窗的显示/隐藏
onClose function 关闭裁剪弹窗的回调函数
onCropComplete function 裁剪完成回调,接收裁剪后的图片 DataURL

Props 详细说明

imageSrc: string
  • 说明:要裁剪的图片源
  • 支持格式
    • 网络图片 URL:"https://example.com/image.jpg"
    • Base64 DataURL:"data:image/png;base64,..."
    • Blob URL:URL.createObjectURL(file)
isOpen: boolean
  • 说明:控制裁剪弹窗的显示状态
  • 示例true 显示弹窗,false 隐藏弹窗
onClose: () => void
  • 说明:关闭裁剪弹窗时触发的回调
  • 使用场景:清理状态、取消操作
onCropComplete: (croppedImageUrl: string, cropInfo?: object) => void
  • 说明:裁剪完成时触发的回调
  • 参数
    • croppedImageUrl:裁剪后的图片 DataURL(PNG 格式)
    • cropInfo(可选):裁剪信息对象
      {
        displayWidth: number,    // 显示时的宽度
        displayHeight: number,  // 显示时的高度
        originalWidth: number,  // 原始像素宽度
        originalHeight: number  // 原始像素高度
      }
      

🚀 基本使用

最简单的示例

import React, { useState } from 'react'
import ImageCropper from './components/ImageCropper'

function App() {
  const [isOpen, setIsOpen] = useState(false)
  const [imageSrc, setImageSrc] = useState('')

  // 打开裁剪器
  const handleOpen = () => {
    setImageSrc('https://example.com/image.jpg')
    setIsOpen(true)
  }

  // 关闭裁剪器
  const handleClose = () => {
    setIsOpen(false)
  }

  // 处理裁剪完成
  const handleCropComplete = (croppedImageUrl) => {
    console.log('裁剪完成,图片 DataURL:', croppedImageUrl)
    // 可以在这里保存图片或更新状态
    setIsOpen(false)
  }

  return (
    <div>
      <button onClick={handleOpen}>打开裁剪器</button>
      
      <ImageCropper
        imageSrc={imageSrc}
        isOpen={isOpen}
        onClose={handleClose}
        onCropComplete={handleCropComplete}
      />
    </div>
  )
}

📝 完整示例

示例 1:图片列表裁剪

import React, { useState } from 'react'
import ImageCropper from './components/ImageCropper'

function ImageList() {
  const [images, setImages] = useState([
    'https://picsum.photos/400/300?random=1',
    'https://picsum.photos/500/400?random=2',
  ])

  // 裁剪器状态
  const [cropperState, setCropperState] = useState({
    isOpen: false,
    imageSrc: null,
    imageIndex: null,
  })

  // 双击图片打开裁剪器
  const handleImageDoubleClick = (imageSrc, index) => {
    setCropperState({
      isOpen: true,
      imageSrc: imageSrc,
      imageIndex: index,
    })
  }

  // 处理裁剪完成
  const handleCropComplete = (croppedImageUrl) => {
    if (cropperState.imageIndex !== null) {
      // 替换原图片为裁剪后的图片
      const newImages = [...images]
      newImages[cropperState.imageIndex] = croppedImageUrl
      setImages(newImages)
    }
    // 关闭裁剪器
    setCropperState({
      isOpen: false,
      imageSrc: null,
      imageIndex: null,
    })
  }

  // 关闭裁剪器
  const handleClose = () => {
    setCropperState({
      isOpen: false,
      imageSrc: null,
      imageIndex: null,
    })
  }

  return (
    <div>
      {/* 图片列表 */}
      <div className="grid grid-cols-3 gap-4">
        {images.map((src, index) => (
          <img
            key={index}
            src={src}
            alt={`图片 ${index + 1}`}
            onDoubleClick={() => handleImageDoubleClick(src, index)}
            className="cursor-pointer"
          />
        ))}
      </div>

      {/* 裁剪组件 */}
      <ImageCropper
        imageSrc={cropperState.imageSrc}
        isOpen={cropperState.isOpen}
        onClose={handleClose}
        onCropComplete={handleCropComplete}
      />
    </div>
  )
}

示例 2:文件上传裁剪

import React, { useState } from 'react'
import ImageCropper from './components/ImageCropper'

function ImageUpload() {
  const [uploadedImage, setUploadedImage] = useState(null)
  const [isOpen, setIsOpen] = useState(false)
  const [croppedImage, setCroppedImage] = useState(null)

  // 处理文件选择
  const handleFileSelect = (e) => {
    const file = e.target.files?.[0]
    if (file) {
      const reader = new FileReader()
      reader.onload = (event) => {
        const imageUrl = event.target.result
        setUploadedImage(imageUrl)
        setIsOpen(true) // 自动打开裁剪器
      }
      reader.readAsDataURL(file)
    }
  }

  // 处理裁剪完成
  const handleCropComplete = (croppedImageUrl, cropInfo) => {
    setCroppedImage(croppedImageUrl)
    setIsOpen(false)
    console.log('裁剪信息:', cropInfo)
  }

  // 下载裁剪后的图片
  const handleDownload = () => {
    if (croppedImage) {
      const link = document.createElement('a')
      link.download = 'cropped-image.png'
      link.href = croppedImage
      link.click()
    }
  }

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={handleFileSelect}
      />

      {croppedImage && (
        <div>
          <h3>裁剪后的图片:</h3>
          <img src={croppedImage} alt="裁剪后" />
          <button onClick={handleDownload}>下载图片</button>
        </div>
      )}

      <ImageCropper
        imageSrc={uploadedImage}
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        onCropComplete={handleCropComplete}
      />
    </div>
  )
}

示例 3:获取裁剪信息

const handleCropComplete = (croppedImageUrl, cropInfo) => {
  console.log('裁剪后的图片:', croppedImageUrl)
  
  if (cropInfo) {
    console.log('显示尺寸:', {
      width: cropInfo.displayWidth,
      height: cropInfo.displayHeight
    })
    console.log('原始像素尺寸:', {
      width: cropInfo.originalWidth,
      height: cropInfo.originalHeight
    })
  }

  // 可以用于上传到服务器
  // uploadToServer(croppedImageUrl, cropInfo)
}

🎨 操作说明

裁剪器操作

  1. 调整裁剪区域

    • 拖动 8 个控制点(四个角和四条边的中点)调整裁剪框大小
    • 拖动裁剪框内部可以移动裁剪框位置
  2. 移动图片

    • 拖动裁剪框外的空白区域可以移动图片位置
  3. 缩放图片

    • 点击工具栏的放大/缩小按钮
    • 缩放范围:0.1x - 3x
  4. 重置

    • 点击重置按钮恢复初始状态
  5. 确认裁剪

    • 点击"确认裁剪"按钮完成裁剪
    • 点击"取消"或点击背景关闭裁剪器

⚠️ 注意事项

1. 图片跨域问题

如果使用网络图片 URL,可能会遇到跨域问题。解决方案:

// 方案 1:使用代理服务器
const imageSrc = 'https://your-proxy.com/image.jpg'

// 方案 2:使用 CORS 配置的图片服务器
// 确保图片服务器设置了正确的 CORS 头

// 方案 3:转换为 DataURL(推荐)
const reader = new FileReader()
reader.onload = (e) => {
  setImageSrc(e.target.result) // 使用 DataURL
}
reader.readAsDataURL(file)

2. 大图片处理

对于超大图片,组件会自动缩放以适应屏幕,但裁剪时会使用原始分辨率,确保输出质量。

3. 内存管理

裁剪后的图片是 DataURL 格式,可能占用较大内存。建议:

// 使用完图片后释放内存
const handleCropComplete = (croppedImageUrl) => {
  // 处理图片...
  
  // 如果不再需要,可以转换为 Blob 减少内存占用
  const blob = dataURLtoBlob(croppedImageUrl)
  const blobUrl = URL.createObjectURL(blob)
  
  // 使用完后记得释放
  // URL.revokeObjectURL(blobUrl)
}

function dataURLtoBlob(dataurl) {
  const arr = dataurl.split(',')
  const mime = arr[0].match(/:(.*?);/)[1]
  const bstr = atob(arr[1])
  let n = bstr.length
  const u8arr = new Uint8Array(n)
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n)
  }
  return new Blob([u8arr], { type: mime })
}

4. 样式依赖

组件使用了 Tailwind CSS 的 className。如果项目中没有 Tailwind,需要:

  • 安装 Tailwind CSS
  • 或者将 className 替换为内联样式

🔍 常见问题

Q1: 裁剪后的图片质量下降?

A: 组件会保持原始分辨率裁剪,不会降低质量。如果遇到质量问题,检查:

  • 原始图片分辨率
  • 浏览器 Canvas 限制
  • 导出格式(PNG 无损)

Q2: 移动端触摸不灵敏?

A: 组件已针对移动端优化,控制点检测距离在移动端会自动增大。如果仍有问题,检查:

  • 触摸事件是否被其他元素阻止
  • 是否有 CSS touch-action 冲突

Q3: 如何限制裁剪区域的最小/最大尺寸?

A: 当前版本最小尺寸固定为 50x50 像素。如需自定义,可以修改组件源码中的限制逻辑:

// 在 handleMove 函数中
if (newCrop.width >= minWidth && newCrop.height >= minHeight) {
  // 允许调整
}

Q4: 如何自定义裁剪框样式?

A: 需要修改组件源码中的绘制代码:

// 修改裁剪框颜色
ctx.strokeStyle = "#your-color"

// 修改控制点样式
ctx.fillStyle = "#your-color"
ctx.strokeStyle = "#your-color"

🔗 相关资源

源码:

"use client"

import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from "react"

/**
 * 简单的图片裁剪组件
 * 支持8个控制点:上下左右 + 四个角
 */
export default function ImageCropper({ imageSrc, isOpen, onClose, onCropComplete }) {
  const canvasRef = useRef(null)
  const [image, setImage] = useState(null)
  const [crop, setCrop] = useState({ x: 50, y: 50, width: 200, height: 200 })
  const [dragging, setDragging] = useState(null)
  const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
  const [scale, setScale] = useState(1)
  const [imagePos, setImagePos] = useState({ x: 0, y: 0 })
  const [isDraggingImage, setIsDraggingImage] = useState(false)
  const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
  
  // 使用 ref 存储最新的处理函数,避免依赖项问题
  const handlersRef = useRef(null)

  // 加载图片
  useEffect(() => {
    if (!isOpen || !imageSrc) return

    const img = new Image()
    img.crossOrigin = "anonymous"
    img.onload = () => {
      setImage(img)
      
      // 计算适合的尺寸 - 使用更大的空间以保持清晰度
      const maxWidth = window.innerWidth * 0.85
      const maxHeight = window.innerHeight * 0.75
      
      // 使用原始图片尺寸,但限制在合理范围内
      let width = img.width
      let height = img.height
      
      // 如果图片太大,按比例缩小,但保持尽可能大以维持清晰度
      if (width > maxWidth || height > maxHeight) {
        const scaleX = maxWidth / width
        const scaleY = maxHeight / height
        const scale = Math.min(scaleX, scaleY)
        width = Math.floor(width * scale)
        height = Math.floor(height * scale)
      }
      
      setCanvasSize({ width, height })
      setImagePos({ x: 0, y: 0 })
      setScale(1)
      
      // 设置初始裁剪区域(居中,60%大小)
      const cropSize = Math.min(width, height) * 0.6
      setCrop({
        x: (width - cropSize) / 2,
        y: (height - cropSize) / 2,
        width: cropSize,
        height: cropSize,
      })
    }
    img.src = imageSrc
  }, [isOpen, imageSrc])

  // 绘制画布
  useEffect(() => {
    if (!canvasRef.current || !image) return

    const canvas = canvasRef.current
    const ctx = canvas.getContext("2d")
    
    // 设置高质量渲染
    ctx.imageSmoothingEnabled = true
    ctx.imageSmoothingQuality = "high"
    
    // 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    
    // 绘制图片(带缩放和位移)
    const scaledWidth = canvasSize.width * scale
    const scaledHeight = canvasSize.height * scale
    const x = imagePos.x
    const y = imagePos.y
    
    // 先绘制完整图片
    ctx.drawImage(image, x, y, scaledWidth, scaledHeight)
    
    // 绘制遮罩(分四个区域,避免覆盖裁剪区域)
    ctx.fillStyle = "rgba(0, 0, 0, 0.5)"
    
    // 上方遮罩
    if (crop.y > 0) {
      ctx.fillRect(0, 0, canvas.width, crop.y)
    }
    
    // 下方遮罩
    if (crop.y + crop.height < canvas.height) {
      ctx.fillRect(0, crop.y + crop.height, canvas.width, canvas.height - crop.y - crop.height)
    }
    
    // 左侧遮罩
    if (crop.x > 0) {
      ctx.fillRect(0, crop.y, crop.x, crop.height)
    }
    
    // 右侧遮罩
    if (crop.x + crop.width < canvas.width) {
      ctx.fillRect(crop.x + crop.width, crop.y, canvas.width - crop.x - crop.width, crop.height)
    }
    
    // 绘制裁剪框边框
    ctx.strokeStyle = "#3b82f6"
    ctx.lineWidth = 2
    ctx.strokeRect(crop.x, crop.y, crop.width, crop.height)
    
    // 绘制网格线
    ctx.strokeStyle = "#3b82f6"
    ctx.lineWidth = 1
    ctx.beginPath()
    ctx.moveTo(crop.x + crop.width / 3, crop.y)
    ctx.lineTo(crop.x + crop.width / 3, crop.y + crop.height)
    ctx.moveTo(crop.x + (crop.width * 2) / 3, crop.y)
    ctx.lineTo(crop.x + (crop.width * 2) / 3, crop.y + crop.height)
    ctx.moveTo(crop.x, crop.y + crop.height / 3)
    ctx.lineTo(crop.x + crop.width, crop.y + crop.height / 3)
    ctx.moveTo(crop.x, crop.y + (crop.height * 2) / 3)
    ctx.lineTo(crop.x + crop.width, crop.y + (crop.height * 2) / 3)
    ctx.stroke()
    
    // 绘制8个控制点
    const handles = [
      { x: crop.x, y: crop.y, cursor: "nw-resize" }, // 左上
      { x: crop.x + crop.width / 2, y: crop.y, cursor: "n-resize" }, // 上
      { x: crop.x + crop.width, y: crop.y, cursor: "ne-resize" }, // 右上
      { x: crop.x + crop.width, y: crop.y + crop.height / 2, cursor: "e-resize" }, // 右
      { x: crop.x + crop.width, y: crop.y + crop.height, cursor: "se-resize" }, // 右下
      { x: crop.x + crop.width / 2, y: crop.y + crop.height, cursor: "s-resize" }, // 下
      { x: crop.x, y: crop.y + crop.height, cursor: "sw-resize" }, // 左下
      { x: crop.x, y: crop.y + crop.height / 2, cursor: "w-resize" }, // 左
    ]
    
    // 检测是否为移动设备,增大控制点尺寸
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
    const handleRadius = isMobile ? 10 : 6
    
    ctx.fillStyle = "#fff"
    ctx.strokeStyle = "#3b82f6"
    ctx.lineWidth = 2
    handles.forEach((handle) => {
      ctx.beginPath()
      ctx.arc(handle.x, handle.y, handleRadius, 0, Math.PI * 2)
      ctx.fill()
      ctx.stroke()
    })
  }, [image, crop, canvasSize, scale, imagePos])

  // 获取坐标(统一处理鼠标和触摸事件)
  const getCoordinates = (e) => {
    const rect = canvasRef.current.getBoundingClientRect()
    if (e.touches && e.touches.length > 0) {
      // 触摸事件
      return {
        x: e.touches[0].clientX - rect.left,
        y: e.touches[0].clientY - rect.top,
        clientX: e.touches[0].clientX,
        clientY: e.touches[0].clientY,
      }
    } else {
      // 鼠标事件
      return {
        x: e.clientX - rect.left,
        y: e.clientY - rect.top,
        clientX: e.clientX,
        clientY: e.clientY,
      }
    }
  }

  // 开始拖动(统一处理鼠标和触摸)
  const handleStart = (e) => {
    e.preventDefault() // 防止移动端默认行为(滚动、缩放等)
    const coords = getCoordinates(e)
    const x = coords.x
    const y = coords.y
    
    // 检测是否为移动设备,调整触摸检测距离
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
    const touchThreshold = isMobile ? 20 : 10
    
    // 检查是否点击了控制点
    const handles = [
      { name: "nw", x: crop.x, y: crop.y },
      { name: "n", x: crop.x + crop.width / 2, y: crop.y },
      { name: "ne", x: crop.x + crop.width, y: crop.y },
      { name: "e", x: crop.x + crop.width, y: crop.y + crop.height / 2 },
      { name: "se", x: crop.x + crop.width, y: crop.y + crop.height },
      { name: "s", x: crop.x + crop.width / 2, y: crop.y + crop.height },
      { name: "sw", x: crop.x, y: crop.y + crop.height },
      { name: "w", x: crop.x, y: crop.y + crop.height / 2 },
    ]
    
    for (const handle of handles) {
      const dist = Math.sqrt((x - handle.x) ** 2 + (y - handle.y) ** 2)
      if (dist < touchThreshold) {
        setDragging({ type: "handle", name: handle.name, startX: x, startY: y, startCrop: { ...crop } })
        return
      }
    }
    
    // 检查是否在裁剪框内(移动裁剪框)
    if (x >= crop.x && x <= crop.x + crop.width && y >= crop.y && y <= crop.y + crop.height) {
      setDragging({ type: "move", startX: x, startY: y, startCrop: { ...crop } })
      return
    }
    
    // 否则拖动图片
    setIsDraggingImage(true)
    setDragStart({ x: coords.clientX - imagePos.x, y: coords.clientY - imagePos.y })
  }

  // 拖动中(统一处理鼠标和触摸)
  const handleMove = (e) => {
    e.preventDefault() // 防止移动端默认行为
    const coords = getCoordinates(e)
    
    // 拖动图片
    if (isDraggingImage) {
      setImagePos({
        x: coords.clientX - dragStart.x,
        y: coords.clientY - dragStart.y,
      })
      return
    }
    
    if (!dragging) return
    
    const x = coords.x
    const y = coords.y
    const dx = x - dragging.startX
    const dy = y - dragging.startY
    
    if (dragging.type === "move") {
      setCrop({
        ...dragging.startCrop,
        x: Math.max(0, Math.min(canvasSize.width - dragging.startCrop.width, dragging.startCrop.x + dx)),
        y: Math.max(0, Math.min(canvasSize.height - dragging.startCrop.height, dragging.startCrop.y + dy)),
      })
    } else if (dragging.type === "handle") {
      const newCrop = { ...dragging.startCrop }
      
      switch (dragging.name) {
        case "nw":
          newCrop.x += dx
          newCrop.y += dy
          newCrop.width -= dx
          newCrop.height -= dy
          break
        case "n":
          newCrop.y += dy
          newCrop.height -= dy
          break
        case "ne":
          newCrop.y += dy
          newCrop.width += dx
          newCrop.height -= dy
          break
        case "e":
          newCrop.width += dx
          break
        case "se":
          newCrop.width += dx
          newCrop.height += dy
          break
        case "s":
          newCrop.height += dy
          break
        case "sw":
          newCrop.x += dx
          newCrop.width -= dx
          newCrop.height += dy
          break
        case "w":
          newCrop.x += dx
          newCrop.width -= dx
          break
      }
      
      // 限制最小尺寸
      if (newCrop.width >= 50 && newCrop.height >= 50) {
        // 限制在画布内
        if (newCrop.x >= 0 && newCrop.y >= 0 && 
            newCrop.x + newCrop.width <= canvasSize.width && 
            newCrop.y + newCrop.height <= canvasSize.height) {
          setCrop(newCrop)
        }
      }
    }
  }

  // 结束拖动(统一处理鼠标和触摸)
  const handleEnd = (e) => {
    e.preventDefault()
    setDragging(null)
    setIsDraggingImage(false)
  }

  // 鼠标事件(保持兼容性)
  const handleMouseDown = (e) => handleStart(e)
  const handleMouseMove = (e) => handleMove(e)
  const handleMouseUp = (e) => handleEnd(e)

  // 使用 useLayoutEffect 确保在 DOM 更新后立即更新 ref
  useLayoutEffect(() => {
    handlersRef.current = { 
      handleStart, 
      handleMove, 
      handleEnd,
      dragging,
      isDraggingImage,
      dragStart,
      imagePos,
      crop,
      canvasSize
    }
  })

  // 使用 useEffect 手动注册非被动的触摸事件监听器
  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas || !handlersRef.current) return

    // 触摸事件处理器(直接从 ref 获取最新的处理函数)
    const touchStartHandler = (e) => {
      e.preventDefault()
      e.stopPropagation()
      const handlers = handlersRef.current
      if (handlers?.handleStart) {
        handlers.handleStart(e)
      }
    }
    
    const touchMoveHandler = (e) => {
      e.preventDefault()
      e.stopPropagation()
      const handlers = handlersRef.current
      if (handlers?.handleMove) {
        handlers.handleMove(e)
      }
    }
    
    const touchEndHandler = (e) => {
      e.preventDefault()
      e.stopPropagation()
      const handlers = handlersRef.current
      if (handlers?.handleEnd) {
        handlers.handleEnd(e)
      }
    }

    // 添加非被动事件监听器
    canvas.addEventListener('touchstart', touchStartHandler, { passive: false })
    canvas.addEventListener('touchmove', touchMoveHandler, { passive: false })
    canvas.addEventListener('touchend', touchEndHandler, { passive: false })
    canvas.addEventListener('touchcancel', touchEndHandler, { passive: false })

    return () => {
      canvas.removeEventListener('touchstart', touchStartHandler)
      canvas.removeEventListener('touchmove', touchMoveHandler)
      canvas.removeEventListener('touchend', touchEndHandler)
      canvas.removeEventListener('touchcancel', touchEndHandler)
    }
  }, [image, canvasSize]) // 当图片和画布尺寸变化时重新注册

  // 确认裁剪
  const handleCrop = () => {
    if (!image) return
    
    const canvas = document.createElement("canvas")
    const ctx = canvas.getContext("2d")
    
    // 计算原始图片上的裁剪区域
    const scaledWidth = canvasSize.width * scale
    const scaledHeight = canvasSize.height * scale
    
    // 裁剪区域相对于缩放后图片的位置
    const relativeX = crop.x - imagePos.x
    const relativeY = crop.y - imagePos.y
    
    // 转换到原始图片的坐标(原始像素)
    const sourceX = (relativeX / scaledWidth) * image.width
    const sourceY = (relativeY / scaledHeight) * image.height
    const sourceWidth = (crop.width / scaledWidth) * image.width
    const sourceHeight = (crop.height / scaledHeight) * image.height
    
    // 设置输出画布尺寸为原始像素尺寸(保持高清)
    canvas.width = sourceWidth
    canvas.height = sourceHeight
    
    // 启用高质量图像平滑
    ctx.imageSmoothingEnabled = true
    ctx.imageSmoothingQuality = "high"
    
    // 从原始图片裁剪,输出高清图片
    ctx.drawImage(
      image,
      sourceX, sourceY, sourceWidth, sourceHeight,
      0, 0, sourceWidth, sourceHeight
    )
    
    // 使用高质量 PNG 输出
    const croppedImage = canvas.toDataURL("image/png", 1.0)
    
    // 传递裁剪信息(包括原始显示尺寸)
    onCropComplete(croppedImage, {
      displayWidth: crop.width,
      displayHeight: crop.height,
      originalWidth: sourceWidth,
      originalHeight: sourceHeight,
    })
    
    handleClose()
  }

  // 关闭
  const handleClose = () => {
    setImage(null)
    onClose()
  }

  // 缩放
  const handleZoom = (delta) => {
    setScale((prev) => Math.max(0.1, Math.min(3, prev + delta)))
  }

  // 重置
  const handleReset = () => {
    setScale(1)
    setImagePos({ x: 0, y: 0 })
    if (canvasSize.width && canvasSize.height) {
      const cropSize = Math.min(canvasSize.width, canvasSize.height) * 0.6
      setCrop({
        x: (canvasSize.width - cropSize) / 2,
        y: (canvasSize.height - cropSize) / 2,
        width: cropSize,
        height: cropSize,
      })
    }
  }

  if (!isOpen) return null

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
      onClick={(e) => e.target === e.currentTarget && handleClose()}
      style={{ touchAction: "none" }} // 防止移动端背景滚动
    >
      <div className="relative w-[95vw] h-[95vh] max-w-7xl max-h-[90vh] bg-white dark:bg-[#111111] rounded-lg shadow-2xl flex flex-col overflow-hidden md:rounded-lg">
        {/* 头部工具栏 */}
        <div className="flex items-center justify-between px-3 md:px-6 py-3 md:py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-[#0a0a0a]">
          <h2 className="text-lg md:text-xl font-semibold text-gray-800 dark:text-gray-200">裁剪图片</h2>
          
          <div className="flex items-center gap-1 md:gap-2">
            <button
              onClick={() => handleZoom(0.1)}
              className="px-2 md:px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-[#1f1f1f] border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-[#2a2a2a] active:bg-gray-100 dark:active:bg-[#333333]"
              title="放大"
            >
              <svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" />
              </svg>
            </button>
            
            <button
              onClick={() => handleZoom(-0.1)}
              className="px-2 md:px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-[#1f1f1f] border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-[#2a2a2a] active:bg-gray-100 dark:active:bg-[#333333]"
              title="缩小"
            >
              <svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
              </svg>
            </button>
            
            <div className="w-px h-6 bg-gray-300 dark:bg-[#2a2a2a] mx-1"></div>
            
            <button
              onClick={handleReset}
              className="px-2 md:px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-[#1f1f1f] border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-[#2a2a2a] active:bg-gray-100 dark:active:bg-[#333333]"
              title="重置"
            >
              <svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
              </svg>
            </button>
          </div>

          <button
            onClick={handleClose}
            className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-[#2a2a2a] active:bg-gray-300 dark:active:bg-[#2a2a2a] rounded-full"
          >
            <svg className="w-5 h-5 md:w-6 md:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        </div>

        {/* 裁剪区域 */}
        <div className="flex-1 overflow-hidden p-4 bg-gray-100 dark:bg-[#0a0a0a] flex items-center justify-center">
          <canvas
            ref={canvasRef}
            width={canvasSize.width}
            height={canvasSize.height}
            className="cursor-move border border-gray-300 dark:border-gray-600 touch-none"
            style={{
              imageRendering: "high-quality",
              WebkitFontSmoothing: "antialiased",
              touchAction: "none", // 防止移动端默认触摸行为
            }}
            onMouseDown={handleMouseDown}
            onMouseMove={handleMouseMove}
            onMouseUp={handleMouseUp}
            onMouseLeave={handleMouseUp}
          />
        </div>

        {/* 底部操作栏 */}
        <div className="flex items-center justify-end gap-3 px-3 md:px-6 py-3 md:py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-[#0a0a0a]">
          <button
            onClick={handleClose}
            className="px-4 md:px-6 py-2.5 md:py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-[#1f1f1f] border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-[#2a2a2a] active:bg-gray-100 dark:active:bg-[#333333] min-h-[44px] md:min-h-0"
          >
            取消
          </button>
          <button
            onClick={handleCrop}
            className="px-4 md:px-6 py-2.5 md:py-2.5 text-sm font-medium text-white bg-blue-600 dark:bg-blue-500 rounded-md hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-700 min-h-[44px] md:min-h-0"
          >
            确认裁剪
          </button>
        </div>
      </div>

      {/* 使用提示 */}
      <div className="absolute bottom-4 md:bottom-8 left-1/2 transform -translate-x-1/2 bg-black/75 text-white px-3 md:px-6 py-2 md:py-3 rounded-lg text-xs md:text-sm backdrop-blur-sm max-w-[90vw] text-center">
        💡 提示:拖动8个控制点调整裁剪区域 | 拖动裁剪框移动位置 | 拖动空白处移动图片
      </div>
    </div>
  )
}


作者: IMeL
最后更新: 2025-01-01

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐