arka triymfalnaya

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);
}

идея простая:


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");
};

итог

в итоге это не просто кроппер.

это:

и самое интересное — чем дальше ты заходишь, тем меньше это похоже на ui-компонент и тем больше на мини-редактор изображений внутри браузера.

source code