图片裁剪

2025-07-27 13:02:15 | 前端Number of visitors 55


// 父组件
<template>
  <DialogImgCropper
    v-model:dialogVisible="dialogCropperVisible"
    v-model:targetWidth="cropperWidth"
    v-model:targetHeight="cropperHeight"
    v-model:externalFile="cropperOriginFile"
    @data="handleCroppedData"
  />
</template>

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'

import { useUserStore } from '@/store/modules/user'

import DialogImgCropper from './components/DialogImgCropper.vue'

const userStore = useUserStore()

const fileEl = useTemplateRef('fileEl')


const dialogCropperVisible = ref(false)

const cropperWidth = ref(300)
const cropperHeight = ref(300)
const cropperOriginFile = ref<any>(null)
</script>
// children.vue
<template>
  <Dialog v-model="dialogVisible" width="80%" title="图片裁剪" @close="handleClose">
    <div class="p-4">
      <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
        <!-- 左侧:图片上传和裁剪区域 -->
        <div class="lg:col-span-2 bg-[var(--app-view-bg-color)] rounded-lg shadow-md p-4">
          <!-- 图片上传 -->
          <div class="mb-4">
            <label class="block text-sm font-medium mb-2">选择图片</label>
            <input
              ref="uploadFileEl"
              type="file"
              @change="handleFileUpload"
              accept="image/jpg,image/jpeg,image/png,image/bmp,image/gif"
              class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
            />
          </div>

          <!-- 图片显示与裁剪区域 -->
          <div
            v-if="!imageUrl"
            class="h-64 flex items-center justify-center border-2 border-dashed border-gray-300 rounded-lg cursor-pointer"
            @click="uploadFileEl?.click()"
          >
            <p class="text-gray-400">请上传一张图片</p>
          </div>

          <div v-else class="relative overflow-hidden border-2 border-gray-200 h-[400px]">
            <img
              v-if="imageUrl"
              :src="imageUrl"
              alt="原始图片"
              ref="imageRef"
              class="max-w-full max-h-full object-contain"
              @load="onImageLoad"
            />

            <!-- 可调整大小的裁剪框 -->
            <div
              v-if="isCropping"
              ref="cropArea"
              class="absolute border-2 border-blue-500 bg-blue-100/30"
              :style="{
                left: `${cropPosition.x}px`,
                top: `${cropPosition.y}px`,
                width: `${cropWidth}px`,
                height: `${cropHeight}px`,
                cursor: isResizing ? 'se-resize' : 'move'
              }"
              @mousedown.stop="handleCropAreaMouseDown"
            >
              <!-- 尺寸显示 -->
              <div class="absolute top-0 left-0 bg-white text-blue-600 text-xs p-0.5">
                {{ Math.round(cropWidth) }}x{{ Math.round(cropHeight) }}
              </div>
              <!-- 右下角调整手柄 -->
              <div
                class="absolute right-0 bottom-0 w-4 h-4 bg-blue-500 border-2 border-white cursor-se-resize"
                @mousedown.stop="startResize"
              ></div>
            </div>
          </div>

          <!-- 操作按钮 -->
          <div class="mt-4 flex flex-wrap gap-2">
            <button
              @click="startCrop"
              :disabled="!imageUrl"
              class="border-none bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
            >
              开始裁剪
            </button>
            <button
              @click="cancelCrop"
              :disabled="!isCropping"
              class="border-none bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition-colors"
            >
              取消裁剪
            </button>
            <button
              @click="confirmCrop"
              :disabled="!isCropping || cropWidth < minWidth || cropHeight < minHeight"
              class="border-none bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
            >
              确认裁剪
            </button>
          </div>
        </div>

        <!-- 右侧:实时预览与结果 -->
        <div class="bg-[var(--app-view-bg-color)] rounded-lg shadow-md p-4">
          <h2 class="text-xl font-semibold mb-4">实时预览</h2>
          <div
            class="relative overflow-hidden border-2 border-gray-200"
            :style="{
              width: `${targetWidth}px`,
              height: `${targetHeight}px`,
              margin: '0 auto'
            }"
          >
            <div
              v-if="!isCropping || !previewImageUrl"
              class="h-full flex items-center justify-center"
            >
              <p class="text-gray-400 text-sm">拖动裁剪框或调整大小以查看预览</p>
            </div>
            <img v-else :src="previewImageUrl" alt="预览图" class="w-full h-full object-cover" />
          </div>

          <!-- 裁剪结果与上传 -->
          <div class="mt-4">
            <h3 class="text-lg font-medium mb-2">裁剪结果</h3>
            <div
              class="relative overflow-hidden border-2 border-gray-200"
              :style="{
                width: `${targetWidth}px`,
                height: `${targetHeight}px`,
                margin: '0 auto'
              }"
            >
              <img
                v-if="croppedImageUrl"
                :src="croppedImageUrl"
                alt="裁剪后图片"
                class="w-full h-full object-cover"
              />
              <div v-else class="h-full flex items-center justify-center">
                <p class="text-gray-400 text-sm">请完成裁剪</p>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    <template #footer>
      <ElButton @click="handleClose">关闭</ElButton>
      <ElButton type="primary" :loading="isUploading" @click="uploadImageFn">确定</ElButton>
    </template>
  </Dialog>
</template>

<script lang="ts" setup>
import {
  ref,
  reactive,
  watch,
  computed,
  onMounted,
  nextTick,
  onUnmounted,
  useTemplateRef
} from 'vue'
import { ElMessage } from 'element-plus'

const emit = defineEmits(['data'])
const dialogVisible = defineModel<boolean>('dialogVisible', { default: false })
const targetWidth = defineModel<any>('targetWidth')
const targetHeight = defineModel<any>('targetHeight')

// 从父组件接收参数,包括可能变化的文件流
const {
  minWidth = 20,
  minHeight = 20,
  externalFile
} = defineProps<{
  minWidth?: number // 最小裁剪宽度
  minHeight?: number // 最小裁剪高度
  externalFile?: File | null // 父组件传入的文件流
}>()

// 计算宽高比
const aspectRatio = computed(() => targetWidth.value / targetHeight.value)

// 初始裁剪区域大小为目标尺寸的1/2
const initialCropWidthBase = ref(targetWidth.value / 2)
const initialCropHeightBase = ref(targetHeight.value / 2)

const uploadFileEl = useTemplateRef('uploadFileEl')
const imageRef = useTemplateRef('imageRef')
const cropArea = ref<HTMLDivElement | null>(null)
const imageUrl = ref<string | null>(null)
const croppedImageUrl = ref<string | null>(null)
const previewImageUrl = ref<string | null>(null)
const isCropping = ref(false)
const isResizing = ref(false)
const isDragging = ref(false)
const isUploading = ref(false)
const animationFrameId = ref<any>(null)
const previewUpdateTimer = ref<any>(null)
const cropperFileInfo = ref<any>(null)

// 裁剪区域状态
const cropPosition = reactive({ x: 0, y: 0 })
const cropWidth = ref(initialCropWidthBase.value)
const cropHeight = ref(initialCropHeightBase.value)
const start = reactive({ x: 0, y: 0, width: 0, height: 0 })
const containerRect = ref<any>({ width: 0, height: 0, left: 0, top: 0 })

// 监听对话框显示状态
watch(
  () => dialogVisible.value,
  (val) => {
    if (val) {
      initialCropWidthBase.value = targetWidth.value / 2
      initialCropHeightBase.value = targetHeight.value / 2
      cropWidth.value = initialCropWidthBase.value
      cropHeight.value = initialCropHeightBase.value

      // 如果有外部文件,加载它
      if (externalFile) {
        handleExternalFile(externalFile)
      }
    }
  }
)

// 监听目标尺寸变化
watch([targetWidth, targetHeight], () => {
  initialCropWidthBase.value = targetWidth.value / 2
  initialCropHeightBase.value = targetHeight.value / 2

  if (isCropping.value) {
    cropWidth.value = Math.max(minWidth, initialCropWidthBase.value)
    cropHeight.value = cropWidth.value / aspectRatio.value

    if (cropHeight.value < minHeight) {
      cropHeight.value = minHeight
      cropWidth.value = cropHeight.value * aspectRatio.value
    }

    nextTick(resetCropArea)
  } else {
    cropWidth.value = initialCropWidthBase.value
    cropHeight.value = initialCropHeightBase.value
  }
})

// 监听外部文件变化
watch(
  () => externalFile,
  (newFile, oldFile) => {
    // 只有当文件真正变化时才处理
    if (newFile && newFile !== oldFile) {
      handleExternalFile(newFile)
    }
  },
  { immediate: true } // 立即执行,处理初始传入的文件
)

// 处理父组件传入的文件流
const handleExternalFile = (file: File) => {
  // 清除现有图片数据
  resetImageData()

  const reader = new FileReader()
  reader.onload = (event) => {
    imageUrl.value = event.target?.result as string
    cropperFileInfo.value = file
    // 初始化裁剪区域
    cropWidth.value = initialCropWidthBase.value
    cropHeight.value = initialCropHeightBase.value
    isCropping.value = false
  }
  reader.readAsDataURL(file)
}

// 图片加载处理
const onImageLoad = () => {
  nextTick(() => {
    if (!imageRef.value) return
    containerRect.value = imageRef.value.parentElement!.getBoundingClientRect()
    resetCropArea()
  })
}

// 处理文件上传
const handleFileUpload = (e: Event) => {
  const file = (e.target as HTMLInputElement).files?.[0]
  if (file) {
    // 清除可能存在的外部文件影响
    const inputElement = e.target as HTMLInputElement
    inputElement.value = '' // 重置input,允许重复选择同一文件

    const reader = new FileReader()
    reader.onload = (event) => {
      imageUrl.value = event.target?.result as string
      cropperFileInfo.value = file
      cropWidth.value = initialCropWidthBase.value
      cropHeight.value = initialCropHeightBase.value
      isCropping.value = false
    }
    reader.readAsDataURL(file)
  }
}

// 开始裁剪
const startCrop = () => {
  if (imageUrl.value) {
    isCropping.value = true
    nextTick(resetCropArea)
  }
}

// 重置裁剪区域
const resetCropArea = () => {
  if (!imageRef.value) return
  const img = imageRef.value as HTMLImageElement
  const imgRect = img.getBoundingClientRect()

  // 以目标尺寸的1/2为基准计算初始裁剪大小
  const maxPossibleWidth = imgRect.width
  const maxPossibleHeight = imgRect.height / aspectRatio.value

  const adjustedWidth = Math.min(
    initialCropWidthBase.value,
    Math.min(maxPossibleWidth, maxPossibleHeight * aspectRatio.value)
  )

  cropWidth.value = adjustedWidth
  cropHeight.value = adjustedWidth / aspectRatio.value

  // 确保不小于最小尺寸限制
  cropWidth.value = Math.max(minWidth, cropWidth.value)
  cropHeight.value = Math.max(minHeight, cropHeight.value)

  // 确保裁剪框在图片内部居中显示
  cropPosition.x = Math.max(0, (imgRect.width - cropWidth.value) / 2)
  cropPosition.y = Math.max(0, (imgRect.height - cropHeight.value) / 2)

  updatePreview()
}

// 取消裁剪
const cancelCrop = () => {
  isCropping.value = false
  previewImageUrl.value = null
  cropWidth.value = initialCropWidthBase.value
  cropHeight.value = initialCropHeightBase.value
}

// 重置图片数据
const resetImageData = () => {
  imageUrl.value = null
  croppedImageUrl.value = null
  previewImageUrl.value = null
  isCropping.value = false
  cropperFileInfo.value = null
}

// 鼠标按下事件处理
const handleCropAreaMouseDown = (e: any) => {
  if (e.target?.classList.contains('cursor-se-resize')) {
    startResize(e)
  } else {
    startDragging(e)
  }
}

// 开始拖动
const startDragging = (e: MouseEvent) => {
  if (!isCropping.value) return
  isDragging.value = true
  start.x = e.clientX
  start.y = e.clientY

  document.addEventListener('mousemove', handleMouseMove)
  document.addEventListener('mouseup', handleMouseUp)
}

// 开始调整大小
const startResize = (e: MouseEvent) => {
  if (!isCropping.value) return
  isResizing.value = true
  start.x = e.clientX
  start.y = e.clientY
  start.width = cropWidth.value
  start.height = cropHeight.value

  document.addEventListener('mousemove', handleMouseMove)
  document.addEventListener('mouseup', handleMouseUp)
}

// 鼠标移动处理
const handleMouseMove = (e: MouseEvent) => {
  if (!isCropping.value || !imageRef.value) return

  cancelAnimationFrame(animationFrameId.value)
  animationFrameId.value = requestAnimationFrame(() => {
    const img = imageRef.value as HTMLImageElement
    const imgRect = img.getBoundingClientRect()

    if (isResizing.value) {
      // 调整大小逻辑,保持宽高比
      const deltaWidth = e.clientX - start.x
      const deltaHeight = (e.clientY - start.y) * aspectRatio.value
      const actualDelta = Math.min(deltaWidth, deltaHeight)

      let newWidth = start.width + actualDelta
      let newHeight = newWidth / aspectRatio.value

      // 限制最小尺寸
      newWidth = Math.max(minWidth, newWidth)
      newHeight = Math.max(minHeight, newHeight)

      // 关键修改:限制最大尺寸不超过图片大小(保持宽高比)
      const maxImgWidth = imgRect.width
      const maxImgHeight = imgRect.height

      // 先按宽度限制(如果超过图片宽度)
      if (newWidth > maxImgWidth) {
        newWidth = maxImgWidth
        newHeight = newWidth / aspectRatio.value
      }

      // 再按高度限制(如果按宽度限制后仍超过图片高度)
      if (newHeight > maxImgHeight) {
        newHeight = maxImgHeight
        newWidth = newHeight * aspectRatio.value
      }

      // 最终赋值
      cropWidth.value = newWidth
      cropHeight.value = newHeight

      // 确保裁剪框位置不超出图片(防止左上角超出)
      cropPosition.x = Math.max(0, Math.min(cropPosition.x, maxImgWidth - cropWidth.value))
      cropPosition.y = Math.max(0, Math.min(cropPosition.y, maxImgHeight - cropHeight.value))
    } else if (isDragging.value) {
      // 拖动逻辑(保持不变)
      const dx = e.clientX - start.x
      const dy = e.clientY - start.y

      // 限制在图片范围内
      cropPosition.x = Math.max(0, Math.min(cropPosition.x + dx, imgRect.width - cropWidth.value))
      cropPosition.y = Math.max(0, Math.min(cropPosition.y + dy, imgRect.height - cropHeight.value))

      start.x = e.clientX
      start.y = e.clientY
    }

    // 防抖更新预览
    clearTimeout(previewUpdateTimer.value)
    previewUpdateTimer.value = setTimeout(updatePreview, 100) as unknown as number
  })
}

// 鼠标释放处理
const handleMouseUp = () => {
  isDragging.value = false
  isResizing.value = false
  document.removeEventListener('mousemove', handleMouseMove)
  document.removeEventListener('mouseup', handleMouseUp)
  updatePreview()
}

// 更新预览
const updatePreview = () => {
  if (!imageUrl.value || !isCropping.value || !cropArea.value) return

  const img = new Image()
  img.src = imageUrl.value
  img.onload = () => {
    const canvas = document.createElement('canvas')
    canvas.width = targetWidth.value
    canvas.height = targetHeight.value
    const ctx = canvas.getContext('2d')!

    if (!imageRef.value) return
    const imgElement = imageRef.value as HTMLImageElement
    const imgRect = imgElement.getBoundingClientRect()
    const scaleX = img.naturalWidth / imgRect.width
    const scaleY = img.naturalHeight / imgRect.height

    const x = cropPosition.x * scaleX
    const y = cropPosition.y * scaleY
    const w = cropWidth.value * scaleX
    const h = cropHeight.value * scaleY

    ctx.drawImage(img, x, y, w, h, 0, 0, targetWidth.value, targetHeight.value)
    previewImageUrl.value = canvas.toDataURL('image/png')
  }
}

// 确认裁剪
const confirmCrop = () => {
  if (!cropperFileInfo.value || cropWidth.value < minWidth || cropHeight.value < minHeight) return

  const img = new Image()
  img.src = imageUrl.value!
  img.onload = () => {
    const canvas = document.createElement('canvas')
    canvas.width = targetWidth.value
    canvas.height = targetHeight.value
    const ctx = canvas.getContext('2d')!

    const imgElement = imageRef.value as HTMLImageElement
    const imgRect = imgElement.getBoundingClientRect()
    const scaleX = img.naturalWidth / imgRect.width
    const scaleY = img.naturalHeight / imgRect.height

    const x = cropPosition.x * scaleX
    const y = cropPosition.y * scaleY
    const w = cropWidth.value * scaleX
    const h = cropHeight.value * scaleY

    ctx.drawImage(img, x, y, w, h, 0, 0, targetWidth.value, targetHeight.value)

    // 生成文件流
    canvas.toBlob(
      (blob) => {
        if (blob) {
          const file = new File([blob], cropperFileInfo.value!.name, {
            type: cropperFileInfo.value!.type,
            lastModified: Date.now()
          })
          cropperFileInfo.value = file
          croppedImageUrl.value = URL.createObjectURL(blob)
        }
      },
      cropperFileInfo.value!.type,
      0.92
    )
  }
}

// 生命周期处理
onMounted(() => {
  document.addEventListener('mousemove', handleMouseMove)
  document.addEventListener('mouseup', handleMouseUp)
})

onUnmounted(() => {
  document.removeEventListener('mousemove', handleMouseMove)
  document.removeEventListener('mouseup', handleMouseUp)
  cancelAnimationFrame(animationFrameId.value)
  clearTimeout(previewUpdateTimer.value)
})

const handleClose = () => {
  resetImageData()
  dialogVisible.value = false
}

const uploadImageFn = () => {
  if (!cropperFileInfo.value) {
    ElMessage.warning({
      message: '请先裁剪图片',
      showClose: true,
      plain: true
    })
    return
  }
  emit('data', cropperFileInfo.value)
  dialogVisible.value = false
}
</script>



send发送