图片编辑器

Canvas图片编辑器开发指南,涵盖滤镜效果、裁剪旋转、调整亮度对比度、标注工具等功能实现。Canvas提供了强大的图像处理能力,本节将介绍如何开发一个功能完善的图片编辑器。

基础图像操作

图像加载与显示

class ImageEditor {
  constructor(canvasId) {
    this.canvas = document.getElementById(canvasId)
    this.ctx = this.canvas.getContext('2d')
    
    this.image = null
    this.originalImageData = null
    
    this.transform = {
      x: 0,
      y: 0,
      scale: 1,
      rotation: 0
    }
    
    this.history = []
    this.historyIndex = -1
  }
  
  loadImage(src) {
    return new Promise((resolve, reject) => {
      const img = new Image()
      img.crossOrigin = 'anonymous'
      
      img.onload = () => {
        this.image = img
        this.canvas.width = img.width
        this.canvas.height = img.height
        
        this.ctx.drawImage(img, 0, 0)
        this.originalImageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
        
        this.saveState()
        resolve(img)
      }
      
      img.onerror = reject
      img.src = src
    })
  }
  
  loadImageFromFile(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      
      reader.onload = (e) => {
        this.loadImage(e.target.result).then(resolve).catch(reject)
      }
      
      reader.onerror = reject
      reader.readAsDataURL(file)
    })
  }
  
  reset() {
    if (this.originalImageData) {
      this.ctx.putImageData(this.originalImageData, 0, 0)
      this.saveState()
    }
  }
  
  saveState() {
    this.history = this.history.slice(0, this.historyIndex + 1)
    this.history.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height))
    this.historyIndex++
  }
  
  undo() {
    if (this.historyIndex > 0) {
      this.historyIndex--
      this.ctx.putImageData(this.history[this.historyIndex], 0, 0)
    }
  }
  
  redo() {
    if (this.historyIndex < this.history.length - 1) {
      this.historyIndex++
      this.ctx.putImageData(this.history[this.historyIndex], 0, 0)
    }
  }
  
  export(format = 'image/png', quality = 0.9) {
    return this.canvas.toDataURL(format, quality)
  }
  
  download(filename = 'edited-image.png') {
    const link = document.createElement('a')
    link.download = filename
    link.href = this.export()
    link.click()
  }
}

滤镜效果

基础滤镜

class ImageFilters {
  constructor(ctx) {
    this.ctx = ctx
  }
  
  applyFilter(filterFn) {
    const imageData = this.ctx.getImageData(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
    filterFn(imageData.data)
    this.ctx.putImageData(imageData, 0, 0)
  }
  
  grayscale() {
    this.applyFilter((data) => {
      for (let i = 0; i < data.length; i += 4) {
        const avg = (data[i] + data[i + 1] + data[i + 2]) / 3
        data[i] = data[i + 1] = data[i + 2] = avg
      }
    })
  }
  
  sepia() {
    this.applyFilter((data) => {
      for (let i = 0; i < data.length; i += 4) {
        const r = data[i]
        const g = data[i + 1]
        const b = data[i + 2]
        
        data[i] = Math.min(255, r * 0.393 + g * 0.769 + b * 0.189)
        data[i + 1] = Math.min(255, r * 0.349 + g * 0.686 + b * 0.168)
        data[i + 2] = Math.min(255, r * 0.272 + g * 0.534 + b * 0.131)
      }
    })
  }
  
  invert() {
    this.applyFilter((data) => {
      for (let i = 0; i < data.length; i += 4) {
        data[i] = 255 - data[i]
        data[i + 1] = 255 - data[i + 1]
        data[i + 2] = 255 - data[i + 2]
      }
    })
  }
  
  brightness(value) {
    this.applyFilter((data) => {
      for (let i = 0; i < data.length; i += 4) {
        data[i] = Math.min(255, Math.max(0, data[i] + value))
        data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + value))
        data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + value))
      }
    })
  }
  
  contrast(value) {
    const factor = (259 * (value + 255)) / (255 * (259 - value))
    
    this.applyFilter((data) => {
      for (let i = 0; i < data.length; i += 4) {
        data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128))
        data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128))
        data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128))
      }
    })
  }
  
  saturation(value) {
    this.applyFilter((data) => {
      for (let i = 0; i < data.length; i += 4) {
        const avg = (data[i] + data[i + 1] + data[i + 2]) / 3
        data[i] = Math.min(255, Math.max(0, avg + (data[i] - avg) * value))
        data[i + 1] = Math.min(255, Math.max(0, avg + (data[i + 1] - avg) * value))
        data[i + 2] = Math.min(255, Math.max(0, avg + (data[i + 2] - avg) * value))
      }
    })
  }
  
  hueRotate(degrees) {
    const angle = degrees * Math.PI / 180
    const cos = Math.cos(angle)
    const sin = Math.sin(angle)
    
    this.applyFilter((data) => {
      for (let i = 0; i < data.length; i += 4) {
        const r = data[i]
        const g = data[i + 1]
        const b = data[i + 2]
        
        data[i] = r * (0.213 + cos * 0.787 - sin * 0.213) +
                  g * (0.715 - cos * 0.715 - sin * 0.715) +
                  b * (0.072 - cos * 0.072 + sin * 0.928)
        
        data[i + 1] = r * (0.213 - cos * 0.213 + sin * 0.143) +
                      g * (0.715 + cos * 0.285 + sin * 0.140) +
                      b * (0.072 - cos * 0.072 - sin * 0.283)
        
        data[i + 2] = r * (0.213 - cos * 0.213 - sin * 0.787) +
                      g * (0.715 - cos * 0.715 + sin * 0.715) +
                      b * (0.072 + cos * 0.928 + sin * 0.072)
      }
    })
  }
  
  blur(radius) {
    const imageData = this.ctx.getImageData(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
    const data = imageData.data
    const width = this.ctx.canvas.width
    const height = this.ctx.canvas.height
    const copy = new Uint8ClampedArray(data)
    
    const kernelSize = radius * 2 + 1
    const kernel = []
    let sum = 0
    
    for (let i = 0; i < kernelSize; i++) {
      const value = Math.exp(-((i - radius) ** 2) / (2 * (radius / 3) ** 2))
      kernel.push(value)
      sum += value
    }
    
    for (let i = 0; i < kernelSize; i++) {
      kernel[i] /= sum
    }
    
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        let r = 0, g = 0, b = 0
        
        for (let k = -radius; k <= radius; k++) {
          const px = Math.min(width - 1, Math.max(0, x + k))
          const idx = (y * width + px) * 4
          const weight = kernel[k + radius]
          
          r += copy[idx] * weight
          g += copy[idx + 1] * weight
          b += copy[idx + 2] * weight
        }
        
        const idx = (y * width + x) * 4
        data[idx] = r
        data[idx + 1] = g
        data[idx + 2] = b
      }
    }
    
    this.ctx.putImageData(imageData, 0, 0)
  }
  
  sharpen() {
    const kernel = [
      0, -1, 0,
      -1, 5, -1,
      0, -1, 0
    ]
    
    this.convolve(kernel)
  }
  
  emboss() {
    const kernel = [
      -2, -1, 0,
      -1, 1, 1,
      0, 1, 2
    ]
    
    this.convolve(kernel)
  }
  
  edgeDetect() {
    const kernel = [
      -1, -1, -1,
      -1, 8, -1,
      -1, -1, -1
    ]
    
    this.convolve(kernel)
  }
  
  convolve(kernel) {
    const imageData = this.ctx.getImageData(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
    const data = imageData.data
    const width = this.ctx.canvas.width
    const height = this.ctx.canvas.height
    const copy = new Uint8ClampedArray(data)
    
    const size = Math.sqrt(kernel.length)
    const half = Math.floor(size / 2)
    
    for (let y = half; y < height - half; y++) {
      for (let x = half; x < width - half; x++) {
        let r = 0, g = 0, b = 0
        
        for (let ky = 0; ky < size; ky++) {
          for (let kx = 0; kx < size; kx++) {
            const px = x + kx - half
            const py = y + ky - half
            const idx = (py * width + px) * 4
            const weight = kernel[ky * size + kx]
            
            r += copy[idx] * weight
            g += copy[idx + 1] * weight
            b += copy[idx + 2] * weight
          }
        }
        
        const idx = (y * width + x) * 4
        data[idx] = Math.min(255, Math.max(0, r))
        data[idx + 1] = Math.min(255, Math.max(0, g))
        data[idx + 2] = Math.min(255, Math.max(0, b))
      }
    }
    
    this.ctx.putImageData(imageData, 0, 0)
  }
}

滤镜示例

图片滤镜效果

裁剪与旋转

裁剪工具

class CropTool {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    
    this.cropArea = null
    this.isDragging = false
    this.dragHandle = null
    this.handles = []
    
    this.bindEvents()
  }
  
  bindEvents() {
    this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this))
    this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this))
    this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this))
  }
  
  startCrop() {
    this.cropArea = {
      x: 50,
      y: 50,
      width: this.canvas.width - 100,
      height: this.canvas.height - 100
    }
    this.updateHandles()
  }
  
  updateHandles() {
    if (!this.cropArea) return
    
    const { x, y, width, height } = this.cropArea
    
    this.handles = [
      { type: 'nw', x: x, y: y },
      { type: 'ne', x: x + width, y: y },
      { type: 'sw', x: x, y: y + height },
      { type: 'se', x: x + width, y: y + height },
      { type: 'n', x: x + width / 2, y: y },
      { type: 's', x: x + width / 2, y: y + height },
      { type: 'w', x: x, y: y + height / 2 },
      { type: 'e', x: x + width, y: y + height / 2 }
    ]
  }
  
  onMouseDown(e) {
    const pos = this.getPos(e)
    
    for (const handle of this.handles) {
      if (this.isNearHandle(pos, handle)) {
        this.isDragging = true
        this.dragHandle = handle.type
        return
      }
    }
    
    if (this.cropArea && this.isInsideCropArea(pos)) {
      this.isDragging = true
      this.dragHandle = 'move'
      this.dragStartX = pos.x
      this.dragStartY = pos.y
    }
  }
  
  onMouseMove(e) {
    if (!this.isDragging || !this.cropArea) return
    
    const pos = this.getPos(e)
    
    if (this.dragHandle === 'move') {
      const dx = pos.x - this.dragStartX
      const dy = pos.y - this.dragStartY
      
      this.cropArea.x = Math.max(0, Math.min(this.canvas.width - this.cropArea.width, this.cropArea.x + dx))
      this.cropArea.y = Math.max(0, Math.min(this.canvas.height - this.cropArea.height, this.cropArea.y + dy))
      
      this.dragStartX = pos.x
      this.dragStartY = pos.y
    } else {
      this.resizeCropArea(pos)
    }
    
    this.updateHandles()
  }
  
  onMouseUp() {
    this.isDragging = false
    this.dragHandle = null
  }
  
  resizeCropArea(pos) {
    const { x, y, width, height } = this.cropArea
    
    switch (this.dragHandle) {
      case 'nw':
        this.cropArea.x = Math.min(pos.x, x + width - 50)
        this.cropArea.y = Math.min(pos.y, y + height - 50)
        this.cropArea.width = x + width - this.cropArea.x
        this.cropArea.height = y + height - this.cropArea.y
        break
      case 'ne':
        this.cropArea.y = Math.min(pos.y, y + height - 50)
        this.cropArea.width = Math.max(50, pos.x - x)
        this.cropArea.height = y + height - this.cropArea.y
        break
      case 'sw':
        this.cropArea.x = Math.min(pos.x, x + width - 50)
        this.cropArea.width = x + width - this.cropArea.x
        this.cropArea.height = Math.max(50, pos.y - y)
        break
      case 'se':
        this.cropArea.width = Math.max(50, pos.x - x)
        this.cropArea.height = Math.max(50, pos.y - y)
        break
      case 'n':
        this.cropArea.y = Math.min(pos.y, y + height - 50)
        this.cropArea.height = y + height - this.cropArea.y
        break
      case 's':
        this.cropArea.height = Math.max(50, pos.y - y)
        break
      case 'w':
        this.cropArea.x = Math.min(pos.x, x + width - 50)
        this.cropArea.width = x + width - this.cropArea.x
        break
      case 'e':
        this.cropArea.width = Math.max(50, pos.x - x)
        break
    }
  }
  
  isNearHandle(pos, handle) {
    const dx = pos.x - handle.x
    const dy = pos.y - handle.y
    return Math.sqrt(dx * dx + dy * dy) < 10
  }
  
  isInsideCropArea(pos) {
    return pos.x >= this.cropArea.x &&
           pos.x <= this.cropArea.x + this.cropArea.width &&
           pos.y >= this.cropArea.y &&
           pos.y <= this.cropArea.y + this.cropArea.height
  }
  
  drawOverlay() {
    if (!this.cropArea) return
    
    const { x, y, width, height } = this.cropArea
    
    this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
    
    this.ctx.fillRect(0, 0, this.canvas.width, y)
    this.ctx.fillRect(0, y + height, this.canvas.width, this.canvas.height - y - height)
    this.ctx.fillRect(0, y, x, height)
    this.ctx.fillRect(x + width, y, this.canvas.width - x - width, height)
    
    this.ctx.strokeStyle = '#fff'
    this.ctx.lineWidth = 2
    this.ctx.setLineDash([5, 5])
    this.ctx.strokeRect(x, y, width, height)
    this.ctx.setLineDash([])
    
    this.handles.forEach(handle => {
      this.ctx.fillStyle = '#fff'
      this.ctx.fillRect(handle.x - 5, handle.y - 5, 10, 10)
      this.ctx.strokeStyle = '#3498db'
      this.ctx.lineWidth = 2
      this.ctx.strokeRect(handle.x - 5, handle.y - 5, 10, 10)
    })
  }
  
  applyCrop() {
    if (!this.cropArea) return null
    
    const { x, y, width, height } = this.cropArea
    const imageData = this.ctx.getImageData(x, y, width, height)
    
    this.canvas.width = width
    this.canvas.height = height
    this.ctx.putImageData(imageData, 0, 0)
    
    this.cropArea = null
    return imageData
  }
  
  getPos(e) {
    const rect = this.canvas.getBoundingClientRect()
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    }
  }
}

旋转工具

class RotateTool {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.angle = 0
  }
  
  rotate(degrees) {
    this.angle = (this.angle + degrees) % 360
    this.applyRotation()
  }
  
  setAngle(degrees) {
    this.angle = degrees % 360
    this.applyRotation()
  }
  
  applyRotation() {
    const radians = this.angle * Math.PI / 180
    const cos = Math.abs(Math.cos(radians))
    const sin = Math.abs(Math.sin(radians))
    
    const originalWidth = this.canvas.width
    const originalHeight = this.canvas.height
    
    const newWidth = Math.ceil(originalWidth * cos + originalHeight * sin)
    const newHeight = Math.ceil(originalWidth * sin + originalHeight * cos)
    
    const tempCanvas = document.createElement('canvas')
    tempCanvas.width = originalWidth
    tempCanvas.height = originalHeight
    const tempCtx = tempCanvas.getContext('2d')
    tempCtx.drawImage(this.canvas, 0, 0)
    
    this.canvas.width = newWidth
    this.canvas.height = newHeight
    
    this.ctx.save()
    this.ctx.translate(newWidth / 2, newHeight / 2)
    this.ctx.rotate(radians)
    this.ctx.drawImage(tempCanvas, -originalWidth / 2, -originalHeight / 2)
    this.ctx.restore()
  }
  
  flipHorizontal() {
    const tempCanvas = document.createElement('canvas')
    tempCanvas.width = this.canvas.width
    tempCanvas.height = this.canvas.height
    const tempCtx = tempCanvas.getContext('2d')
    
    tempCtx.translate(this.canvas.width, 0)
    tempCtx.scale(-1, 1)
    tempCtx.drawImage(this.canvas, 0, 0)
    
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.ctx.drawImage(tempCanvas, 0, 0)
  }
  
  flipVertical() {
    const tempCanvas = document.createElement('canvas')
    tempCanvas.width = this.canvas.width
    tempCanvas.height = this.canvas.height
    const tempCtx = tempCanvas.getContext('2d')
    
    tempCtx.translate(0, this.canvas.height)
    tempCtx.scale(1, -1)
    tempCtx.drawImage(this.canvas, 0, 0)
    
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.ctx.drawImage(tempCanvas, 0, 0)
  }
}

调整工具

色彩调整面板

class ColorAdjustment {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.originalData = null
  }
  
  saveOriginal() {
    this.originalData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
  }
  
  adjust(options) {
    if (!this.originalData) return
    
    const imageData = new ImageData(
      new Uint8ClampedArray(this.originalData.data),
      this.originalData.width,
      this.originalData.height
    )
    
    const data = imageData.data
    const { brightness = 0, contrast = 0, saturation = 1, hue = 0 } = options
    
    const contrastFactor = (259 * (contrast + 255)) / (255 * (259 - contrast))
    const hueRadians = hue * Math.PI / 180
    const hueCos = Math.cos(hueRadians)
    const hueSin = Math.sin(hueRadians)
    
    for (let i = 0; i < data.length; i += 4) {
      let r = data[i]
      let g = data[i + 1]
      let b = data[i + 2]
      
      r = Math.min(255, Math.max(0, r + brightness))
      g = Math.min(255, Math.max(0, g + brightness))
      b = Math.min(255, Math.max(0, b + brightness))
      
      r = Math.min(255, Math.max(0, contrastFactor * (r - 128) + 128))
      g = Math.min(255, Math.max(0, contrastFactor * (g - 128) + 128))
      b = Math.min(255, Math.max(0, contrastFactor * (b - 128) + 128))
      
      const avg = (r + g + b) / 3
      r = Math.min(255, Math.max(0, avg + (r - avg) * saturation))
      g = Math.min(255, Math.max(0, avg + (g - avg) * saturation))
      b = Math.min(255, Math.max(0, avg + (b - avg) * saturation))
      
      if (hue !== 0) {
        const newR = r * (0.213 + hueCos * 0.787 - hueSin * 0.213) +
                     g * (0.715 - hueCos * 0.715 - hueSin * 0.715) +
                     b * (0.072 - hueCos * 0.072 + hueSin * 0.928)
        const newG = r * (0.213 - hueCos * 0.213 + hueSin * 0.143) +
                     g * (0.715 + hueCos * 0.285 + hueSin * 0.140) +
                     b * (0.072 - hueCos * 0.072 - hueSin * 0.283)
        const newB = r * (0.213 - hueCos * 0.213 - hueSin * 0.787) +
                     g * (0.715 - hueCos * 0.715 + hueSin * 0.715) +
                     b * (0.072 + hueCos * 0.928 + hueSin * 0.072)
        
        r = newR
        g = newG
        b = newB
      }
      
      data[i] = r
      data[i + 1] = g
      data[i + 2] = b
    }
    
    this.ctx.putImageData(imageData, 0, 0)
  }
  
  apply() {
    this.originalData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
  }
  
  cancel() {
    if (this.originalData) {
      this.ctx.putImageData(this.originalData, 0, 0)
    }
  }
}

标注工具

文字标注

class AnnotationTool {
  constructor(canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    
    this.annotations = []
    this.selectedAnnotation = null
    
    this.options = {
      fontSize: 16,
      fontFamily: 'Arial',
      color: '#e74c3c',
      backgroundColor: 'rgba(255, 255, 255, 0.8)'
    }
  }
  
  addText(x, y, text) {
    this.annotations.push({
      type: 'text',
      x: x,
      y: y,
      text: text,
      fontSize: this.options.fontSize,
      fontFamily: this.options.fontFamily,
      color: this.options.color,
      backgroundColor: this.options.backgroundColor
    })
  }
  
  addArrow(x1, y1, x2, y2) {
    this.annotations.push({
      type: 'arrow',
      x1: x1,
      y1: y1,
      x2: x2,
      y2: y2,
      color: this.options.color,
      lineWidth: 2
    })
  }
  
  addRectangle(x, y, width, height) {
    this.annotations.push({
      type: 'rectangle',
      x: x,
      y: y,
      width: width,
      height: height,
      color: this.options.color,
      lineWidth: 2
    })
  }
  
  addCircle(x, y, radius) {
    this.annotations.push({
      type: 'circle',
      x: x,
      y: y,
      radius: radius,
      color: this.options.color,
      lineWidth: 2
    })
  }
  
  render() {
    this.annotations.forEach(annotation => {
      switch (annotation.type) {
        case 'text':
          this.renderText(annotation)
          break
        case 'arrow':
          this.renderArrow(annotation)
          break
        case 'rectangle':
          this.renderRectangle(annotation)
          break
        case 'circle':
          this.renderCircle(annotation)
          break
      }
    })
  }
  
  renderText(annotation) {
    const { x, y, text, fontSize, fontFamily, color, backgroundColor } = annotation
    
    this.ctx.font = `${fontSize}px ${fontFamily}`
    const metrics = this.ctx.measureText(text)
    const padding = 5
    
    this.ctx.fillStyle = backgroundColor
    this.ctx.fillRect(
      x - padding,
      y - fontSize - padding,
      metrics.width + padding * 2,
      fontSize + padding * 2
    )
    
    this.ctx.fillStyle = color
    this.ctx.textBaseline = 'bottom'
    this.ctx.fillText(text, x, y)
  }
  
  renderArrow(annotation) {
    const { x1, y1, x2, y2, color, lineWidth } = annotation
    
    const angle = Math.atan2(y2 - y1, x2 - x1)
    const headLength = 15
    
    this.ctx.strokeStyle = color
    this.ctx.lineWidth = lineWidth
    this.ctx.lineCap = 'round'
    
    this.ctx.beginPath()
    this.ctx.moveTo(x1, y1)
    this.ctx.lineTo(x2, y2)
    this.ctx.stroke()
    
    this.ctx.beginPath()
    this.ctx.moveTo(x2, y2)
    this.ctx.lineTo(
      x2 - headLength * Math.cos(angle - Math.PI / 6),
      y2 - headLength * Math.sin(angle - Math.PI / 6)
    )
    this.ctx.moveTo(x2, y2)
    this.ctx.lineTo(
      x2 - headLength * Math.cos(angle + Math.PI / 6),
      y2 - headLength * Math.sin(angle + Math.PI / 6)
    )
    this.ctx.stroke()
  }
  
  renderRectangle(annotation) {
    const { x, y, width, height, color, lineWidth } = annotation
    
    this.ctx.strokeStyle = color
    this.ctx.lineWidth = lineWidth
    this.ctx.strokeRect(x, y, width, height)
  }
  
  renderCircle(annotation) {
    const { x, y, radius, color, lineWidth } = annotation
    
    this.ctx.strokeStyle = color
    this.ctx.lineWidth = lineWidth
    this.ctx.beginPath()
    this.ctx.arc(x, y, radius, 0, Math.PI * 2)
    this.ctx.stroke()
  }
  
  clear() {
    this.annotations = []
  }
  
  remove(index) {
    this.annotations.splice(index, 1)
  }
}

完整图片编辑器

class CompleteImageEditor {
  constructor(canvasId) {
    this.canvas = document.getElementById(canvasId)
    this.ctx = this.canvas.getContext('2d')
    
    this.image = null
    this.originalImageData = null
    
    this.filters = new ImageFilters(this.ctx)
    this.cropTool = new CropTool(this.canvas)
    this.rotateTool = new RotateTool(this.canvas)
    this.colorAdjust = new ColorAdjustment(this.canvas)
    this.annotations = new AnnotationTool(this.canvas)
    
    this.history = []
    this.historyIndex = -1
    
    this.bindKeyboard()
  }
  
  async loadImage(src) {
    const img = new Image()
    img.crossOrigin = 'anonymous'
    
    await new Promise((resolve, reject) => {
      img.onload = resolve
      img.onerror = reject
      img.src = src
    })
    
    this.image = img
    this.canvas.width = img.width
    this.canvas.height = img.height
    this.ctx.drawImage(img, 0, 0)
    
    this.originalImageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
    this.colorAdjust.saveOriginal()
    this.saveState()
  }
  
  saveState() {
    this.history = this.history.slice(0, this.historyIndex + 1)
    this.history.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height))
    this.historyIndex++
    
    if (this.history.length > 50) {
      this.history.shift()
      this.historyIndex--
    }
  }
  
  undo() {
    if (this.historyIndex > 0) {
      this.historyIndex--
      this.ctx.putImageData(this.history[this.historyIndex], 0, 0)
    }
  }
  
  redo() {
    if (this.historyIndex < this.history.length - 1) {
      this.historyIndex++
      this.ctx.putImageData(this.history[this.historyIndex], 0, 0)
    }
  }
  
  reset() {
    if (this.originalImageData) {
      this.ctx.putImageData(this.originalImageData, 0, 0)
      this.saveState()
    }
  }
  
  applyFilter(filterName, ...args) {
    this.filters[filterName](...args)
    this.saveState()
  }
  
  adjustColor(options) {
    this.colorAdjust.adjust(options)
  }
  
  applyColorAdjust() {
    this.colorAdjust.apply()
    this.saveState()
  }
  
  rotate(degrees) {
    this.rotateTool.rotate(degrees)
    this.saveState()
  }
  
  flipHorizontal() {
    this.rotateTool.flipHorizontal()
    this.saveState()
  }
  
  flipVertical() {
    this.rotateTool.flipVertical()
    this.saveState()
  }
  
  startCrop() {
    this.cropTool.startCrop()
  }
  
  applyCrop() {
    this.cropTool.applyCrop()
    this.saveState()
  }
  
  addAnnotation(type, ...args) {
    this.annotations[`add${type.charAt(0).toUpperCase() + type.slice(1)}`](...args)
  }
  
  renderAnnotations() {
    this.annotations.render()
  }
  
  export(format = 'image/png', quality = 0.9) {
    return this.canvas.toDataURL(format, quality)
  }
  
  download(filename = 'edited-image.png') {
    const link = document.createElement('a')
    link.download = filename
    link.href = this.export()
    link.click()
  }
  
  bindKeyboard() {
    document.addEventListener('keydown', (e) => {
      if (e.ctrlKey || e.metaKey) {
        switch (e.key.toLowerCase()) {
          case 'z':
            e.preventDefault()
            e.shiftKey ? this.redo() : this.undo()
            break
          case 's':
            e.preventDefault()
            this.download()
            break
        }
      }
    })
  }
}