Реализация обрезки и редактирования изображений перед загрузкой на сайт

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация обрезки и редактирования изображений перед загрузкой на сайт
Средняя
~2-3 рабочих дня
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация обрезки и редактирования изображений перед загрузкой на сайт

Загрузка изображений без предварительной обработки на клиенте — источник боли для серверной части: неправильные пропорции аватаров, многомегабайтные оригиналы в базе, несоответствие aspect ratio требованиям лейаута. Клиентская обрезка решает это до отправки файла.

Стек и выбор библиотеки

Два основных игрока: Cropper.js (vanilla, ~34 KB gzip) и react-image-crop (React-специфичный, ~6 KB). Для Vue есть vue-cropper. Для сложных сценариев с фильтрами и текстом — Fabric.js или Konva.js, но это избыточно для большинства задач.

Выбор прост:

  • Нужен React — react-image-crop или react-cropper (обёртка над Cropper.js)
  • Нужен vanilla/jQuery — Cropper.js напрямую
  • Нужны повороты, фильтры, текст поверх — Cropper.js с расширениями

Базовая реализация на react-image-crop

import { useState, useRef, useCallback } from 'react'
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from 'react-image-crop'
import 'react-image-crop/dist/ReactCrop.css'

function centerAspectCrop(width: number, height: number, aspect: number): Crop {
  return centerCrop(
    makeAspectCrop({ unit: '%', width: 90 }, aspect, width, height),
    width,
    height
  )
}

export function ImageCropUploader({ aspect = 1, onUpload }: {
  aspect?: number
  onUpload: (blob: Blob) => Promise<void>
}) {
  const [imgSrc, setImgSrc] = useState('')
  const [crop, setCrop] = useState<Crop>()
  const [completedCrop, setCompletedCrop] = useState<PixelCrop>()
  const imgRef = useRef<HTMLImageElement>(null)

  function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
    if (e.target.files?.[0]) {
      const reader = new FileReader()
      reader.addEventListener('load', () => setImgSrc(reader.result?.toString() ?? ''))
      reader.readAsDataURL(e.target.files[0])
    }
  }

  function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
    const { width, height } = e.currentTarget
    setCrop(centerAspectCrop(width, height, aspect))
  }

  const getCroppedBlob = useCallback((): Promise<Blob> => {
    const image = imgRef.current
    if (!image || !completedCrop) throw new Error('No crop data')

    const canvas = document.createElement('canvas')
    const scaleX = image.naturalWidth / image.width
    const scaleY = image.naturalHeight / image.height

    canvas.width = completedCrop.width * scaleX
    canvas.height = completedCrop.height * scaleY

    const ctx = canvas.getContext('2d')!
    ctx.drawImage(
      image,
      completedCrop.x * scaleX,
      completedCrop.y * scaleY,
      completedCrop.width * scaleX,
      completedCrop.height * scaleY,
      0, 0,
      canvas.width,
      canvas.height
    )

    return new Promise((resolve, reject) => {
      canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas empty')), 'image/webp', 0.9)
    })
  }, [completedCrop])

  async function handleSubmit() {
    const blob = await getCroppedBlob()
    await onUpload(blob)
  }

  return (
    <div>
      <input type="file" accept="image/*" onChange={onSelectFile} />
      {imgSrc && (
        <ReactCrop
          crop={crop}
          onChange={setCrop}
          onComplete={setCompletedCrop}
          aspect={aspect}
          minWidth={100}
        >
          <img ref={imgRef} src={imgSrc} onLoad={onImageLoad} />
        </ReactCrop>
      )}
      {completedCrop && (
        <button onClick={handleSubmit}>Загрузить</button>
      )}
    </div>
  )
}

Ключевой момент — масштабирование через naturalWidth / width. Браузер отображает изображение в уменьшенном виде, а вырезать нужно из оригинала.

Отдача через FormData на сервер

async function uploadCroppedImage(blob: Blob): Promise<void> {
  const formData = new FormData()
  // Имя файла с расширением — важно для mime-type на сервере
  formData.append('avatar', blob, `avatar-${Date.now()}.webp`)

  const response = await fetch('/api/users/avatar', {
    method: 'POST',
    headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')!.getAttribute('content')! },
    body: formData,
  })

  if (!response.ok) throw new Error(`Upload failed: ${response.status}`)
}

На Laravel-стороне файл приходит как обычный $request->file('avatar'). Дополнительная серверная обрезка не нужна — размеры уже правильные.

Продвинутый сценарий: Cropper.js с поворотом и зумом

<div>
  <img id="image" src="/original.jpg">
  <button onclick="cropper.rotate(90)">Повернуть</button>
  <button onclick="cropper.zoom(0.1)">+</button>
  <button onclick="cropper.zoom(-0.1)">-</button>
  <button onclick="uploadCropped()">Загрузить</button>
</div>

<script>
const cropper = new Cropper(document.getElementById('image'), {
  aspectRatio: 16 / 9,
  viewMode: 2,           // не выходить за границы изображения
  dragMode: 'move',
  autoCropArea: 0.8,
  restore: false,
  guides: true,
  center: true,
  highlight: false,
  cropBoxMovable: true,
  cropBoxResizable: true,
  toggleDragModeOnDblclick: false,
})

async function uploadCropped() {
  cropper.getCroppedCanvas({
    maxWidth: 1920,
    maxHeight: 1080,
    imageSmoothingEnabled: true,
    imageSmoothingQuality: 'high',
  }).toBlob(async (blob) => {
    const fd = new FormData()
    fd.append('image', blob, 'cover.webp')
    await fetch('/api/upload', { method: 'POST', body: fd })
  }, 'image/webp', 0.85)
}
</script>

Обработка EXIF-ориентации

Мобильные фото часто приходят повёрнутыми из-за метаданных EXIF. Браузер до недавнего времени не учитывал это автоматически. CSS-фикс:

img {
  image-orientation: from-image; /* поддерживается в современных браузерах */
}

Для надёжности — библиотека exifr:

import { parse } from 'exifr'

async function fixOrientation(file: File): Promise<string> {
  const exif = await parse(file, { orientation: true })
  return new Promise(resolve => {
    const reader = new FileReader()
    reader.onload = (e) => {
      const img = new Image()
      img.onload = () => {
        const canvas = document.createElement('canvas')
        const rotation = exif?.Orientation ?? 1
        // применяем трансформацию по таблице EXIF
        applyExifRotation(canvas, img, rotation)
        resolve(canvas.toDataURL())
      }
      img.src = e.target!.result as string
    }
    reader.readAsDataURL(file)
  })
}

Ограничения на размер до обрезки

Загружать 20 МБ оригинал, чтобы потом вырезать 200×200 — расточительство. Проверяем перед показом кропа:

const MAX_SIZE_MB = 10
const MAX_DIMENSION = 4096

function validateImage(file: File): string | null {
  if (file.size > MAX_SIZE_MB * 1024 * 1024) {
    return `Файл слишком большой. Максимум ${MAX_SIZE_MB} МБ`
  }
  return null
}

// Для проверки размеров — только после загрузки в img
function checkDimensions(img: HTMLImageElement): string | null {
  if (img.naturalWidth > MAX_DIMENSION || img.naturalHeight > MAX_DIMENSION) {
    return `Изображение слишком большое. Максимум ${MAX_DIMENSION}px`
  }
  return null
}

Если нужно принять большие файлы — ресайзить до разумных размеров перед кропом через canvas.drawImage с уменьшенными canvas.width/height.

Превью до и после

function CropPreview({ crop, imgRef }: { crop: PixelCrop; imgRef: React.RefObject<HTMLImageElement> }) {
  const previewRef = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    if (!crop || !imgRef.current || !previewRef.current) return

    const img = imgRef.current
    const canvas = previewRef.current
    const ctx = canvas.getContext('2d')!

    const scaleX = img.naturalWidth / img.width
    const scaleY = img.naturalHeight / img.height
    const pixelRatio = window.devicePixelRatio

    canvas.width = 150 * pixelRatio
    canvas.height = 150 * pixelRatio
    ctx.scale(pixelRatio, pixelRatio)

    ctx.drawImage(img,
      crop.x * scaleX, crop.y * scaleY,
      crop.width * scaleX, crop.height * scaleY,
      0, 0, 150, 150
    )
  }, [crop, imgRef])

  return <canvas ref={previewRef} style={{ width: 150, height: 150, borderRadius: '50%' }} />
}

Сроки реализации

Базовая интеграция кропа с загрузкой — 4–6 часов. С поворотом, EXIF-коррекцией, превью, валидацией и адаптивной вёрсткой модального окна — 1–2 дня. Если нужна мультизагрузка с индивидуальным кропом каждого файла — отдельная задача на 3–5 дней.