image cropper на solidjs
иногда кажется, что кроп изображения — это просто ui-компонент: выделил область, обрезал, сохранил.
но когда ты начинаешь делать это руками через canvas, drag’n’drop и пересчёт координат с учётом scale — внезапно получается маленький графический редактор.
в этой статье — разбор image cropper, который я собрал на solidjs: от canvas-рендера до экспорта файла.
контекст
задача была простой на словах:
- загрузить изображение
- дать пользователю выбрать область
- добавить зум
- сохранить результат как файл
но почти сразу стало понятно, что это не dom-задача, а работа с пикселями.
сам компонент
это основной компонент кроппера. он держит всё состояние внутри: canvas, drag, scale, загрузку и экспорт.
import { UploadFile } from "@solid-primitives/upload";
import { clsx } from "clsx";
import { createSignal, onCleanup, onMount, Show } from "solid-js";
import { useLocalize } from "~/context/localize";
import { Button } from "../Button";
import styles from "./ImageCropper.module.scss";
interface CropperProps {
uploadFile: UploadFile;
onSave: (arg0: any) => void;
onDecline?: () => void;
}
export const ImageCropper = (props: CropperProps) => {
let canvasRef: HTMLCanvasElement | undefined;
let imageRef: HTMLImageElement | undefined;
let containerRef: HTMLDivElement | undefined;
const { t } = useLocalize();
const [isLoading, setIsLoading] = createSignal(false);
const [cropData, setCropData] = createSignal({
x: 0,
y: 0,
width: 200,
height: 200,
});
const [isDragging, setIsDragging] = createSignal(false);
const [dragStart, setDragStart] = createSignal({ x: 0, y: 0 });
const [imageLoaded, setImageLoaded] = createSignal(false);
const [scale, setScale] = createSignal(1);
// логика ниже
};
стили: это уже не просто ui
.cropperContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--black-50);
border-radius: var(--comment-radius-md);
}
идея простая:
- центрированная рабочая зона
- ощущение инструмента, а не формы
- минимум отвлекающего ui
canvas как рабочая поверхность
.cropperCanvas {
display: flex;
justify-content: center;
align-items: center;
background: var(--background-color);
border-radius: var(--border-radius);
padding: 10px;
box-shadow: 0 2px 8px var(--shadow-color-medium);
}
canvas визуально отделён — как холст в редакторе.
grab vs dragging
.cropperCanvas canvas {
cursor: grab;
}
.cropperCanvas canvas.dragging {
cursor: grabbing;
}
zoom контролы
.zoomControl {
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
width: 30px;
height: 30px;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
cursor: pointer;
}
адаптивность
@media (max-width: 768px) {
.cropperContainer {
padding: 0.5rem;
}
.cropperCanvas canvas {
max-width: calc(100vw - 2rem);
max-height: 300px;
}
}
как работает рендер
const drawImage = () => {
const ctx = canvasRef.getContext("2d");
if (!ctx || !imageRef) return;
const img = imageRef;
const currentScale = scale();
ctx.clearRect(0, 0, canvas.width, canvas.height);
const displayWidth = img.naturalWidth * currentScale;
const displayHeight = img.naturalHeight * currentScale;
const offsetX = (canvas.width - displayWidth) / 2;
const offsetY = (canvas.height - displayHeight) / 2;
ctx.drawImage(img, offsetX, offsetY, displayWidth, displayHeight);
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const crop = cropData();
ctx.clearRect(crop.x, crop.y, crop.width, crop.height);
ctx.strokeStyle = "#fff";
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height);
};
drag логика
const handleMouseDown = (e: MouseEvent) => {
const rect = canvasRef?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const crop = cropData();
if (
x >= crop.x &&
x <= crop.x + crop.width &&
y >= crop.y &&
y <= crop.y + crop.height
) {
setIsDragging(true);
setDragStart({ x: x - crop.x, y: y - crop.y });
}
};
перемещение области
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging() || !canvasRef) return;
const rect = canvasRef.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const drag = dragStart();
const newX = Math.max(
0,
Math.min(canvasRef.width - cropData().width, x - drag.x),
);
const newY = Math.max(
0,
Math.min(canvasRef.height - cropData().height, y - drag.y),
);
setCropData({ ...cropData(), x: newX, y: newY });
drawImage();
};
экспорт кропа
const cropImage = () => {
const crop = cropData();
const currentScale = scale();
const img = imageRef;
const displayWidth = img.naturalWidth * currentScale;
const displayHeight = img.naturalHeight * currentScale;
const offsetX = (canvas.width - displayWidth) / 2;
const offsetY = (canvas.height - displayHeight) / 2;
const sourceX = (crop.x - offsetX) / currentScale;
const sourceY = (crop.y - offsetY) / currentScale;
const sourceWidth = crop.width / currentScale;
const sourceHeight = crop.height / currentScale;
const cropCanvas = document.createElement("canvas");
cropCanvas.width = 300;
cropCanvas.height = 300;
const ctx = cropCanvas.getContext("2d");
ctx.drawImage(
img,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
300,
300,
);
return cropCanvas;
};
сохранение файла
const handleSave = () => {
const croppedCanvas = cropImage();
croppedCanvas.toBlob((blob) => {
const file = new File([blob], `cropped-${props.uploadFile.name}`, {
type: "image/png",
});
props.onSave(file);
}, "image/png");
};
итог
в итоге это не просто кроппер.
это:
- canvas-рендеринг
- ручная система координат
- drag & zoom
- экспорт в файл
и самое интересное — чем дальше ты заходишь, тем меньше это похоже на ui-компонент и тем больше на мини-редактор изображений внутри браузера.