Реализация Image Editor на сайте
Встроенный редактор изображений нужен там, где пользователь должен обработать фото прямо в браузере: аватарки с кадрированием, генераторы сертификатов, редакторы маркетинговых материалов, инструменты аннотирования скриншотов. Отправлять пользователя в Photoshop — неприемлемо для современного UX.
Стек по сценарию
Кадрирование и ресайз — react-image-crop или cropperjs. Самый частый сценарий — загрузка аватарки.
Полноценный редактор с фильтрами, текстом, слоями — fabric.js поверх Canvas. Весит ~300 КБ, но даёт полный контроль.
Профессиональный уровень (аннотирование, лассо, кисти) — tui-image-editor (Toast UI) или konva.js.
Кадрирование аватарки (react-image-crop)
npm install react-image-crop
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from 'react-image-crop'
import 'react-image-crop/dist/ReactCrop.css'
function AvatarCropper({ onComplete }: { onComplete: (blob: Blob) => void }) {
const [imgSrc, setImgSrc] = useState('')
const [crop, setCrop] = useState<Crop>()
const [completedCrop, setCompletedCrop] = useState<PixelCrop>()
const imgRef = useRef<HTMLImageElement>(null)
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => setImgSrc(reader.result as string)
reader.readAsDataURL(file)
}
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { naturalWidth: width, naturalHeight: height } = e.currentTarget
// Центрируем кроп 1:1 при загрузке
const initialCrop = centerCrop(
makeAspectCrop({ unit: '%', width: 80 }, 1, width, height),
width,
height
)
setCrop(initialCrop)
}
async function getCroppedImg(): Promise<Blob> {
const image = imgRef.current!
const canvas = document.createElement('canvas')
const scaleX = image.naturalWidth / image.width
const scaleY = image.naturalHeight / image.height
canvas.width = completedCrop!.width
canvas.height = completedCrop!.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
completedCrop!.x * scaleX,
completedCrop!.y * scaleY,
completedCrop!.width * scaleX,
completedCrop!.height * scaleY,
0, 0,
completedCrop!.width,
completedCrop!.height
)
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.92)
})
}
return (
<div>
<input type="file" accept="image/*" onChange={onFileChange} />
{imgSrc && (
<>
<ReactCrop
crop={crop}
onChange={setCrop}
onComplete={setCompletedCrop}
aspect={1}
circularCrop
>
<img ref={imgRef} src={imgSrc} onLoad={onImageLoad} />
</ReactCrop>
<button
onClick={async () => {
const blob = await getCroppedImg()
onComplete(blob)
}}
>
Применить
</button>
</>
)}
</div>
)
}
Fabric.js: редактор с наложением текста и фигур
npm install fabric
npm install -D @types/fabric
import { fabric } from 'fabric'
import { useEffect, useRef } from 'react'
function ImageEditor({ imageUrl }: { imageUrl: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const fabricRef = useRef<fabric.Canvas | null>(null)
useEffect(() => {
const canvas = new fabric.Canvas(canvasRef.current!, {
width: 800,
height: 600,
backgroundColor: '#fff',
})
fabricRef.current = canvas
// Загружаем фоновое изображение
fabric.Image.fromURL(imageUrl, (img) => {
img.scaleToWidth(800)
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas))
}, { crossOrigin: 'anonymous' })
return () => canvas.dispose()
}, [imageUrl])
function addText() {
const text = new fabric.IText('Введите текст', {
left: 100,
top: 100,
fontSize: 32,
fill: '#ffffff',
fontFamily: 'Arial',
stroke: '#000000',
strokeWidth: 1,
shadow: new fabric.Shadow({ blur: 4, color: 'rgba(0,0,0,0.5)', offsetX: 2, offsetY: 2 }),
})
fabricRef.current!.add(text)
fabricRef.current!.setActiveObject(text)
}
function addRect() {
const rect = new fabric.Rect({
left: 150,
top: 150,
width: 200,
height: 100,
fill: 'rgba(37,99,235,0.4)',
stroke: '#2563eb',
strokeWidth: 2,
rx: 8,
ry: 8,
})
fabricRef.current!.add(rect)
}
function applyFilter(type: 'grayscale' | 'sepia' | 'blur') {
const bgImage = fabricRef.current!.backgroundImage as fabric.Image
if (!bgImage) return
const filterMap = {
grayscale: new fabric.Image.filters.Grayscale(),
sepia: new fabric.Image.filters.Sepia(),
blur: new fabric.Image.filters.Blur({ blur: 0.05 }),
}
bgImage.filters = [filterMap[type]]
bgImage.applyFilters()
fabricRef.current!.renderAll()
}
function exportImage(): string {
return fabricRef.current!.toDataURL({
format: 'jpeg',
quality: 0.92,
multiplier: 2, // 2x для ретины
})
}
return (
<div>
<div className="flex gap-2 mb-4">
<button onClick={addText}>Добавить текст</button>
<button onClick={addRect}>Прямоугольник</button>
<button onClick={() => applyFilter('grayscale')}>Ч/Б</button>
<button onClick={() => applyFilter('sepia')}>Сепия</button>
<button onClick={() => {
const dataUrl = exportImage()
const a = document.createElement('a')
a.href = dataUrl
a.download = 'edited.jpg'
a.click()
}}>
Скачать
</button>
</div>
<canvas ref={canvasRef} />
</div>
)
}
Обрезка изображения до нужного соотношения на сервере
Для автоматической обрезки без участия пользователя — Sharp на Node.js:
// На бэкенде (Next.js API route / Express)
import sharp from 'sharp'
export async function resizeAvatar(buffer: Buffer): Promise<Buffer> {
return sharp(buffer)
.resize(400, 400, {
fit: 'cover',
position: 'attention', // Smart crop — фокус на лица
})
.webp({ quality: 85 })
.toBuffer()
}
attention в Sharp использует saliency detection — умный кроп, который сохраняет лица и важные объекты в кадре.
Что делаем
Уточняем сценарий: просто кроп аватарки — это одна задача, редактор маркетинговых баннеров с текстом и фильтрами — совсем другая. Под задачу выбираем библиотеку, реализуем UI инструментов, экспорт в нужный формат (PNG/JPEG/WebP), интеграцию с формой загрузки и API.
Срок: кроппер аватарки — 1 день. Полноценный редактор на Fabric.js — 4–6 дней.







