Skip to content

实现 图片处理工具

分类:developer | 标签:图片、格式转换、裁剪、旋转、工具 在线图片处理,支持格式转换、裁剪、旋转、翻转、缩放等操作

2.1 功能说明

在线图片处理,支持格式转换、裁剪、旋转、翻转、缩放等操作

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

本工具目录 ImageProcessor/ 下除 index.vue 外,还包含以下源文件:

  • BgColorTool.vue(226 行)
  • CropTool.vue(312 行)
  • FormatConvert.vue(88 行)
  • HelpTab.vue(149 行)
  • ImageCanvas.vue(655 行)
  • MosaicTool.vue(431 行)
  • ResizeTool.vue(90 行)
  • RotateFlip.vue(90 行)
  • WatermarkTool.vue(135 行)
  • bgSegmenter.js(224 行)

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • onCompressFiles()
  • loadImageEl()
  • compressOne()
  • canvasToBlob()
  • compressAll()
  • downloadOne()
  • downloadZip()
  • clearCompress()
  • makeZip()
  • crcTable()
  • crc32()
  • formatFileSize()
  • triggerUpload()
  • handleFileSelect()
  • handleDrop()
  • loadImage()
  • renderCanvas()
  • updateOutputSize()
  • resetTransformState()
  • drawWatermark()
  • drawSingle()
  • onFormatChange()
  • onRotateFlipChange()
  • onResizeChange()
  • onWatermarkChange()
  • onWatermarkDragToggle()
  • onWatermarkDrag()
  • onMosaicChange()
  • onMosaicSelectToggle()
  • onMosaicRegionAdd()
  • onMosaicRegionUpdate()
  • onMosaicRegionRemove()
  • onMosaicRegionsChange()
  • onApplyMosaic()
  • applyMosaicToCanvas()
  • applyMosaicEffect()
  • applyBlurEffect()
  • applySolidEffect()
  • onCropStart()
  • onCropCancel()
  • onCropApplied()
  • onCropRatioChange()
  • onApplyBgColor()
  • onCropMouseDown()
  • onCropMouseMove()
  • onCropMouseUp()
  • downloadImage()
  • resetAll()
  • removeImage()
  • goBack()
  • handleKey()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, reactive, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
  ArrowLeft,
  Upload,
  View,
  Delete,
  RefreshRight,
  Download,
  Tools
} from '@element-plus/icons-vue'
import FeedbackForm from '@/components/FeedbackForm.vue'
import HelpTab from './HelpTab.vue'
import FormatConvert from './FormatConvert.vue'
import CropTool from './CropTool.vue'
import RotateFlip from './RotateFlip.vue'
import ResizeTool from './ResizeTool.vue'
import ImageCanvas from './ImageCanvas.vue'
import WatermarkTool from './WatermarkTool.vue'
import MosaicTool from './MosaicTool.vue'
import BgColorTool from './BgColorTool.vue'

const router = useRouter()

const fileInputRef = ref(null)
const imageCanvasRef = ref(null)
const formatConvertRef = ref(null)
const cropToolRef = ref(null)
const rotateFlipRef = ref(null)
const resizeToolRef = ref(null)
const watermarkToolRef = ref(null)
const mosaicToolRef = ref(null)
const bgColorToolRef = ref(null)

const activeTab = ref('editor')
const activeFunc = ref('format')
const previewUrl = ref(null)
const originalImage = ref(null)
const isWatermarkDragging = ref(false)
const watermarkPos = reactive({ x: 50, y: 50 })
const isMosaicSelecting = ref(false)
const mosaicRegions = ref([])

const originalInfo = reactive({ width: 0, height: 0, format: '', size: '' })
const outputInfo = reactive({ format: '', size: '' })

const functions = [
  { key: 'format', icon: '🔄', label: '格式' },
  { key: 'crop', icon: '✂️', label: '裁剪' },
  { key: 'rotate', icon: '🔃', label: '旋转' },
  { key: 'resize', icon: '📐', label: '缩放' },
  { key: 'watermark', icon: '💧', label: '水印' },
  { key: 'mosaic', icon: '🔒', label: '打码' },
  { key: 'bgColor', icon: '🎨', label: '换底' },
]

// ---- 批量压缩(独立 Tab,不依赖编辑器画布管线)----
const cmpFmt = ref('image/webp')
const cmpQuality = ref(0.8)
const cmpMax = ref(0)
const cmpTarget = ref(0)
const cmpFiles = ref([])
const cmpResults = ref([])
const cmpErr = ref('')

function onCompressFiles(e) {
  const list = Array.from(e.target.files || [])
  if (!list.length) return
  cmpFiles.value = list.map(f => ({ name: f.name, file: f, size: f.size }))
  cmpResults.value = []
  cmpErr.value = ''
}

function loadImageEl(file) {
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(file)
    const img = new Image()
    img.onload = () => resolve({ img, url })
    img.onerror = reject
    img.src = url
  })
}

async function compressOne(item) {
  const { img, url } = await loadImageEl(item.file)
  let w = img.width, h = img.height
  if (cmpMax.value > 0 && Math.max(w, h) > cmpMax.value) {
    const s = cmpMax.value / Math.max(w, h)
    w = Math.max(1, Math.round(w * s)); h = Math.max(1, Math.round(h * s))
  }
  const canvas = document.createElement('canvas')
  canvas.width = w; canvas.height = h
  canvas.getContext('2d').drawImage(img, 0, 0, w, h)
  URL.revokeObjectURL(url)

  let quality = cmpQuality.value
  if (cmpTarget.value > 0 && cmpFmt.value !== 'image/png') {
    // 二分搜索质量以逼近目标体积
    let lo = 0.1, hi = 1
    for (let i = 0; i < 8; i++) {
      quality = (lo + hi) / 2
      const blob = await canvasToBlob(canvas, cmpFmt.value, quality)
      if (!blob) break
      if (blob.size <= cmpTarget.value * 1024) lo = quality
      else hi = quality
    }
  }
  const blob = await canvasToBlob(canvas, cmpFmt.value, quality)
  if (!blob) throw new Error(item.name + ':当前浏览器不支持该格式导出')
  const compSize = blob.size
  const ratio = Math.round((1 - compSize / item.size) * 100)
  return {
    name: item.name,
    orig: formatFileSize(item.size),
    cmp: formatFileSize(compSize),
    ratio,
    url: URL.createObjectURL(blob),
    blob,
    compSize
  }
}

function canvasToBlob(canvas, type, quality) {
  return new Promise(resolve => {
    try { canvas.toBlob(b => resolve(b), type, quality) }
    catch { resolve(null) }
  })
}

async function compressAll() {
  cmpErr.value = ''
  const results = []
  let failed = []
  for (const item of cmpFiles.value) {
    try { results.push(await compressOne(item)) }
    catch (e) { failed.push(e.message) }
  }
  cmpResults.value = results
  if (failed.length) cmpErr.value = '部分失败:' + failed.join(';')
  else if (results.length) ElMessage.success(`压缩完成,共 ${results.length} 张`)
}

function downloadOne(row) {
  const a = document.createElement('a')
  a.href = row.url; a.download = 'compressed_' + row.name; a.click()
}

function downloadZip() {
  if (!cmpResults.value.length) return
  Promise.all(cmpResults.value.map(async r => ({
    name: 'compressed_' + r.name,
    bytes: new Uint8Array(await r.blob.arrayBuffer())
  }))).then(arr => {
    const zip = makeZip(arr)
    const blob = new Blob([zip], { type: 'application/zip' })
    const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'compressed.zip'; a.click()
    URL.revokeObjectURL(a.href)
  })
}

function clearCompress() {
  cmpResults.value.forEach(r => URL.revokeObjectURL(r.url))
  cmpFiles.value = []; cmpResults.value = []; cmpErr.value = ''
}

// 极简 store 模式 ZIP(无压缩),仅用于本地打包下载
function makeZip(files) {
  const crcTable = (() => {
    const t = new Uint32Array(256)
    for (let n = 0; n < 256; n++) {
      let c = n
      for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)
      t[n] = c >>> 0
    }
    return t
  })()
  const crc32 = (u8) => {
    let c = 0xFFFFFFFF
    for (let i = 0; i < u8.length; i++) c = crcTable[(c ^ u8[i]) & 0xFF] ^ (c >>> 8)
    return (c ^ 0xFFFFFFFF) >>> 0
  }
  const enc = new TextEncoder()
  const chunks = []
  let offset = 0
  const localOffsets = []
  for (const f of files) {
    const nameBytes = enc.encode(f.name)
    const data = f.bytes
    const crc = crc32(data)
    localOffsets.push(offset)
    const header = new DataView(new ArrayBuffer(30))
    header.setUint32(0, 0x04034b50, true)
    header.setUint16(4, 20, true)
    header.setUint16(6, 0, true)
    header.setUint16(8, 0, true)
    header.setUint16(10, 0, true)
    header.setUint16(12, 0, true)
    header.setUint32(14, crc, true)
    header.setUint32(18, data.length, true)
    header.setUint32(22, data.length, true)
    header.setUint16(26, nameBytes.length, true)
    header.setUint16(28, 0, true)
    chunks.push(new Uint8Array(header.buffer), nameBytes, data)
    offset += 30 + nameBytes.length + data.length
  }
  const centralStart = offset
  let centralSize = 0
  for (let i = 0; i < files.length; i++) {
    const f = files[i]
    const nameBytes = enc.encode(f.name)
    const data = f.bytes
    const crc = crc32(data)
    const rec = new DataView(new ArrayBuffer(46))
    rec.setUint32(0, 0x02014b50, true)
    rec.setUint16(4, 20, true)
    rec.setUint16(6, 20, true)
    rec.setUint16(8, 0, true)
    rec.setUint16(10, 0, true)
    rec.setUint16(12, 0, true)
    rec.setUint32(14, crc, true)
    rec.setUint32(18, data.length, true)
    rec.setUint32(22, data.length, true)
    rec.setUint16(26, nameBytes.length, true)
    rec.setUint16(28, 0, true)
    rec.setUint16(30, 0, true)
    rec.setUint16(32, 0, true)
    rec.setUint16(34, 0, true)
    rec.setUint32(36, 0, true)
    rec.setUint32(40, localOffsets[i], true)
    chunks.push(new Uint8Array(rec.buffer), nameBytes)
    centralSize += 46 + nameBytes.length
    offset += 46 + nameBytes.length
  }
  const end = new DataView(new ArrayBuffer(22))
  end.setUint32(0, 0x06054b50, true)
  end.setUint16(4, 0, true)
  end.setUint16(6, 0, true)
  end.setUint16(8, files.length, true)
  end.setUint16(10, files.length, true)
  end.setUint32(12, centralSize, true)
  end.setUint32(16, centralStart, true)
  end.setUint16(20, 0, true)
  chunks.push(new Uint8Array(end.buffer))
  let total = 0
  for (const c of chunks) total += c.length
  const out = new Uint8Array(total)
  let pos = 0
  for (const c of chunks) { out.set(c, pos); pos += c.length }
  return out
}

const formatFileSize = (bytes) => {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
  return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}

const triggerUpload = () => {
  fileInputRef.value?.click()
}

const handleFileSelect = (event) => {
  const file = event.target.files?.[0]
  if (file) loadImage(file)
}

const handleDrop = (event) => {
  const file = event.dataTransfer?.files?.[0]
  if (file) loadImage(file)
}

const loadImage = (file) => {
  if (!file.type.startsWith('image/')) {
    ElMessage.error('请选择图片文件')
    return
  }
  if (file.size > 20 * 1024 * 1024) {
    ElMessage.error('图片大小不能超过20MB')
    return
  }

  const formatMap = {
    'image/png': 'PNG',
    'image/jpeg': 'JPEG',
    'image/webp': 'WebP',
    'image/bmp': 'BMP',
    'image/gif': 'GIF'
  }

  originalInfo.format = formatMap[file.type] || file.type
  originalInfo.size = formatFileSize(file.size)

  const reader = new FileReader()
  reader.onload = (e) => {
    const img = new Image()
    img.onload = () => {
      originalImage.value = img
      originalInfo.width = img.width
      originalInfo.height = img.height
      previewUrl.value = e.target.result
      resetTransformState()
      resizeToolRef.value?.initSize(img.width, img.height)
      nextTick(() => renderCanvas())
      ElMessage.success('图片加载成功')
    }
    img.src = e.target.result
  }
  reader.readAsDataURL(file)
}

const renderCanvas = () => {
  const canvas = imageCanvasRef.value?.canvasRef
  if (!canvas || !originalImage.value) return

  const ctx = canvas.getContext('2d')
  const img = originalImage.value
  const rotation = rotateFlipRef.value?.rotation ?? 0
  const flipH = rotateFlipRef.value?.flipH ?? false
  const flipV = rotateFlipRef.value?.flipV ?? false

  const rad = (rotation * Math.PI) / 180
  const isRotated90 = rotation % 180 !== 0

  const srcW = img.width
  const srcH = img.height

  const rotW = isRotated90 ? srcH : srcW
  const rotH = isRotated90 ? srcW : srcH

  const offscreen = document.createElement('canvas')
  offscreen.width = rotW
  offscreen.height = rotH
  const offCtx = offscreen.getContext('2d')
  offCtx.translate(rotW / 2, rotH / 2)
  offCtx.rotate(rad)
  if (flipH) offCtx.scale(-1, 1)
  if (flipV) offCtx.scale(1, -1)
  offCtx.drawImage(img, -srcW / 2, -srcH / 2, srcW, srcH)

  const outW = resizeToolRef.value?.resizeWidth || rotW
  const outH = resizeToolRef.value?.resizeHeight || rotH
  canvas.width = outW
  canvas.height = outH
  ctx.clearRect(0, 0, outW, outH)
  ctx.drawImage(offscreen, 0, 0, outW, outH)

  const wmConfig = watermarkToolRef.value?.getWatermarkConfig()
  if (wmConfig?.enabled) {
    drawWatermark(ctx, outW, outH, wmConfig)
  }

  updateOutputSize()
}

const updateOutputSize = () => {
  const canvas = imageCanvasRef.value?.canvasRef
  if (!canvas || !originalImage.value) {
    outputInfo.format = ''
    outputInfo.size = ''
    return
  }
  outputInfo.format = formatConvertRef.value?.getFormatLabel() ?? ''
  const mimeType = formatConvertRef.value?.getMimeType() ?? 'image/png'
  const quality = formatConvertRef.value?.getQuality()
  try {
    canvas.toBlob((blob) => {
      if (blob) {
        outputInfo.size = formatFileSize(blob.size)
      }
    }, mimeType, quality)
  } catch {
    outputInfo.size = '计算失败'
  }
}

const resetTransformState = () => {
  formatConvertRef.value?.reset()
  cropToolRef.value?.reset()
  rotateFlipRef.value?.reset()
  watermarkToolRef.value?.reset()
  mosaicToolRef.value?.reset()
  bgColorToolRef.value?.reset()
  isWatermarkDragging.value = false
  watermarkPos.x = 50
  watermarkPos.y = 50
  isMosaicSelecting.value = false
  mosaicRegions.value = []
}

const drawWatermark = (ctx, canvasW, canvasH, config) => {
  const { text, position, fontSize, opacity, angle, color, customX, customY } = config
  ctx.save()
  ctx.globalAlpha = opacity
  ctx.font = `${fontSize}px sans-serif`
  ctx.fillStyle = color
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'

  const rad = (angle * Math.PI) / 180
  const metrics = ctx.measureText(text)
  const textW = metrics.width
  const textH = fontSize

  const drawSingle = (x, y) => {
    ctx.save()
    ctx.translate(x, y)
    ctx.rotate(rad)
    ctx.fillText(text, 0, 0)
    ctx.restore()
  }

  if (position === 'tile') {
    const gapX = textW + fontSize * 2
    const gapY = textH + fontSize * 2
    const diagonal = Math.sqrt(canvasW * canvasW + canvasH * canvasH)
    const startX = -diagonal / 2
    const startY = -diagonal / 2
    for (let y = startY; y < diagonal / 2; y += gapY) {
      for (let x = startX; x < diagonal / 2; x += gapX) {
        drawSingle(canvasW / 2 + x, canvasH / 2 + y)
      }
    }
  } else if (position === 'custom') {
    const x = (customX / 100) * canvasW
    const y = (customY / 100) * canvasH
    drawSingle(x, y)
  } else {
    const padding = fontSize
    let x, y
    switch (position) {
      case 'center':
        x = canvasW / 2
        y = canvasH / 2
        break
      case 'top-left':
        x = padding + textW / 2
        y = padding + textH / 2
        break
      case 'top-right':
        x = canvasW - padding - textW / 2
        y = padding + textH / 2
        break
      case 'bottom-left':
        x = padding + textW / 2
        y = canvasH - padding - textH / 2
        break
      case 'bottom-right':
      default:
        x = canvasW - padding - textW / 2
        y = canvasH - padding - textH / 2
        break
    }
    drawSingle(x, y)
  }

  ctx.restore()
}

const onFormatChange = () => {
  renderCanvas()
}

const onRotateFlipChange = () => {
  if (!originalImage.value) return
  renderCanvas()
}

const onResizeChange = () => {
  renderCanvas()
}

const onWatermarkChange = () => {
  renderCanvas()
}

const onWatermarkDragToggle = (enabled) => {
  isWatermarkDragging.value = enabled
  if (enabled) {
    watermarkPos.x = watermarkToolRef.value?.customX ?? 50
    watermarkPos.y = watermarkToolRef.value?.customY ?? 50
  }
}

const onWatermarkDrag = (pos) => {
  watermarkPos.x = pos.x
  watermarkPos.y = pos.y
  watermarkToolRef.value?.setCustomPosition(pos.x, pos.y)
}

const onMosaicChange = () => {}

const onMosaicSelectToggle = (enabled) => {
  isMosaicSelecting.value = enabled
  if (enabled) {
    activeFunc.value = 'mosaic'
  }
}

const onMosaicRegionAdd = (region) => {
  mosaicToolRef.value?.addManualRegion(region)
  mosaicRegions.value = mosaicToolRef.value?.allRegions || []
}

const onMosaicRegionUpdate = (index, newRegion) => {
  mosaicToolRef.value?.updateRegionPosition(index, newRegion)
  mosaicRegions.value = mosaicToolRef.value?.allRegions || []
}

const onMosaicRegionRemove = (index) => {
  mosaicToolRef.value?.removeRegion(index)
  mosaicRegions.value = mosaicToolRef.value?.allRegions || []
}

const onMosaicRegionsChange = (regions) => {
  mosaicRegions.value = regions
}

const onApplyMosaic = (config) => {
  applyMosaicToCanvas(config)
}

const applyMosaicToCanvas = (config) => {
  const canvas = imageCanvasRef.value?.canvasRef
  if (!canvas || !originalImage.value) return

  const ctx = canvas.getContext('2d')
  const { style, regions, mosaicSize, blurRadius, solidColor } = config

  if (!regions || regions.length === 0) {
    ElMessage.warning('没有需要打码的区域')
    return
  }

  for (const region of regions) {
    const rx = Math.max(0, Math.round(region.x))
    const ry = Math.max(0, Math.round(region.y))
    const rw = Math.min(Math.round(region.w), canvas.width - rx)
    const rh = Math.min(Math.round(region.h), canvas.height - ry)

    if (rw <= 0 || rh <= 0) continue

    if (style === 'mosaic') {
      applyMosaicEffect(ctx, rx, ry, rw, rh, mosaicSize)
    } else if (style === 'blur') {
      applyBlurEffect(ctx, canvas, rx, ry, rw, rh, blurRadius)
    } else if (style === 'solid') {
      applySolidEffect(ctx, rx, ry, rw, rh, solidColor)
    }
  }

  mosaicRegions.value = []
  mosaicToolRef.value?.reset()
  isMosaicSelecting.value = false

  const croppedCanvas = document.createElement('canvas')
  croppedCanvas.width = canvas.width
  croppedCanvas.height = canvas.height
  const croppedCtx = croppedCanvas.getContext('2d')
  croppedCtx.drawImage(canvas, 0, 0)

  const newImg = new Image()
  newImg.onload = () => {
    originalImage.value = newImg
    renderCanvas()
    ElMessage.success('打码已应用')
  }
  newImg.src = croppedCanvas.toDataURL()
}

const applyMosaicEffect = (ctx, x, y, w, h, blockSize) => {
  const size = Math.max(2, blockSize)
  const imageData = ctx.getImageData(x, y, w, h)
  const data = imageData.data

  for (let by = 0; by < h; by += size) {
    for (let bx = 0; bx < w; bx += size) {
      let r = 0, g = 0, b = 0, count = 0
      const bw = Math.min(size, w - bx)
      const bh = Math.min(size, h - by)

      for (let dy = 0; dy < bh; dy++) {
        for (let dx = 0; dx < bw; dx++) {
          const idx = ((by + dy) * w + (bx + dx)) * 4
          r += data[idx]
          g += data[idx + 1]
          b += data[idx + 2]
          count++
        }
      }

      r = Math.round(r / count)
      g = Math.round(g / count)
      b = Math.round(b / count)

      for (let dy = 0; dy < bh; dy++) {
        for (let dx = 0; dx < bw; dx++) {
          const idx = ((by + dy) * w + (bx + dx)) * 4
          data[idx] = r
          data[idx + 1] = g
          data[idx + 2] = b
        }
      }
    }
  }

  ctx.putImageData(imageData, x, y)
}

const applyBlurEffect = (ctx, canvas, x, y, w, h, radius) => {
  const tempCanvas = document.createElement('canvas')
  tempCanvas.width = canvas.width
  tempCanvas.height = canvas.height
  const tempCtx = tempCanvas.getContext('2d')
  tempCtx.filter = `blur(${radius}px)`
  tempCtx.drawImage(canvas, 0, 0)

  ctx.drawImage(
    tempCanvas,
    x, y, w, h,
    x, y, w, h
  )
}

const applySolidEffect = (ctx, x, y, w, h, color) => {
  ctx.save()
  ctx.fillStyle = color
  ctx.fillRect(x, y, w, h)
  ctx.restore()
}

const onCropStart = () => {
  if (!originalImage.value) return
  cropToolRef.value.startCropping(originalImage.value.width, originalImage.value.height)
  activeFunc.value = 'crop'
}

const onCropCancel = () => {}

const onCropApplied = (rect) => {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  const sw = Math.min(rect.w, originalImage.value.width - rect.x)
  const sh = Math.min(rect.h, originalImage.value.height - rect.y)

  canvas.width = sw
  canvas.height = sh
  ctx.drawImage(originalImage.value, rect.x, rect.y, sw, sh, 0, 0, sw, sh)

  const croppedImg = new Image()
  croppedImg.onload = () => {
    originalImage.value = croppedImg
    originalInfo.width = sw
    originalInfo.height = sh
    resizeToolRef.value?.initSize(sw, sh)
    cropToolRef.value?.finishCrop()
    renderCanvas()
    ElMessage.success('裁剪完成')
  }
  croppedImg.src = canvas.toDataURL()
}

const onCropRatioChange = (ratio) => {
  if (!originalImage.value) return
  cropToolRef.value.setCropRectFromRatio(originalImage.value.width, originalImage.value.height)
}

const onApplyBgColor = ({ canvas: sourceCanvas, imageData }) => {
  const canvas = imageCanvasRef.value?.canvasRef
  if (!canvas || !originalImage.value) return

  const tempCanvas = document.createElement('canvas')
  tempCanvas.width = canvas.width
  tempCanvas.height = canvas.height
  const tempCtx = tempCanvas.getContext('2d')
  tempCtx.putImageData(imageData, 0, 0)

  const newImg = new Image()
  newImg.onload = () => {
    originalImage.value = newImg
    renderCanvas()
  }
  newImg.src = tempCanvas.toDataURL()
}

const onCropMouseDown = (e, ctx) => {
  if (!originalImage.value) return
  cropToolRef.value.handleMouseDown(e, ctx.getOverlayRect, ctx.displayScale, ctx.getCanvasOffset)
}

const onCropMouseMove = (e, ctx) => {
  if (!originalImage.value) return
  cropToolRef.value.handleMouseMove(e, ctx.getOverlayRect, ctx.displayScale, ctx.getCanvasOffset, originalImage.value.width, originalImage.value.height)
}

const onCropMouseUp = () => {
  cropToolRef.value?.handleMouseUp()
}

const downloadImage = () => {
  const canvas = imageCanvasRef.value?.canvasRef
  if (!canvas) return

  renderCanvas()

  const mimeType = formatConvertRef.value?.getMimeType() ?? 'image/png'
  const quality = formatConvertRef.value?.getQuality()
  const ext = formatConvertRef.value?.getExt() ?? 'png'

  let dataUrl
  try {
    dataUrl = canvas.toDataURL(mimeType, quality)
  } catch {
    ElMessage.error('当前浏览器不支持该格式的导出')
    return
  }

  const link = document.createElement('a')
  link.download = `processed_${Date.now()}.${ext}`
  link.href = dataUrl
  link.click()
  ElMessage.success('图片已下载')
}

const resetAll = () => {
  if (!originalImage.value) return
  resetTransformState()
  resizeToolRef.value?.initSize(originalImage.value.width, originalImage.value.height)
  renderCanvas()
  ElMessage.success('已重置所有操作')
}

const removeImage = () => {
  previewUrl.value = null
  originalImage.value = null
  originalInfo.width = 0
  originalInfo.height = 0
  originalInfo.format = ''
  originalInfo.size = ''
  outputInfo.format = ''
  outputInfo.size = ''
  resetTransformState()
}

const goBack = () => {
  router.push('/developer')
}

onMounted(() => {
  const handleKey = (e) => {
    if (e.key === 'Escape' && cropToolRef.value?.isCropping) {
      cropToolRef.value.cancelCrop()
    }
  }
  window.addEventListener('keydown', handleKey)
  onUnmounted(() => window.removeEventListener('keydown', handleKey))
})

2.3 效果截图

图片处理工具 效果截图

工具访问地址:https://www.i91tools.com/tools/image-processor