349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import styles from './CropPhoto.module.css';
|
||
|
||
const CropPhoto: React.FC = () => {
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const imageRef = useRef<HTMLImageElement>(null);
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const viewportRef = useRef<HTMLDivElement>(null);
|
||
|
||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||
const [scale, setScale] = useState(1);
|
||
const [minScale, setMinScale] = useState(1);
|
||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||
const [initialTouch, setInitialTouch] = useState<{ distance: number; scale: number } | null>(null);
|
||
|
||
// Эффект для создания и очистки blob URL
|
||
useEffect(() => {
|
||
const file = (location.state as any)?.file;
|
||
if (!file) {
|
||
navigate('/');
|
||
return;
|
||
}
|
||
|
||
// Создаем URL только если его еще нет
|
||
if (!imageUrl) {
|
||
const url = URL.createObjectURL(file);
|
||
setImageUrl(url);
|
||
}
|
||
|
||
// Очищаем URL при размонтировании компонента
|
||
return () => {
|
||
if (imageUrl) {
|
||
URL.revokeObjectURL(imageUrl);
|
||
setImageUrl(null);
|
||
}
|
||
};
|
||
}, [location.state, navigate, imageUrl]);
|
||
|
||
// Отдельный эффект для обработки загруженного изображения
|
||
useEffect(() => {
|
||
if (!imageUrl || !viewportRef.current) return;
|
||
|
||
const img = new Image();
|
||
|
||
img.onload = () => {
|
||
const viewport = viewportRef.current;
|
||
if (!viewport) return;
|
||
|
||
const frame = viewport.querySelector(`.${styles.frame}`);
|
||
if (!frame) return;
|
||
|
||
const viewportRect = viewport.getBoundingClientRect();
|
||
const frameRect = frame.getBoundingClientRect();
|
||
|
||
// Используем размеры рамки обрезки
|
||
const frameWidth = frameRect.width;
|
||
const frameHeight = frameRect.height;
|
||
|
||
// Вычисляем масштаб, чтобы изображение полностью покрывало область обрезки
|
||
const scaleX = frameWidth / img.naturalWidth;
|
||
const scaleY = frameHeight / img.naturalHeight;
|
||
const baseScale = Math.max(scaleX, scaleY);
|
||
|
||
setMinScale(baseScale);
|
||
setScale(baseScale);
|
||
|
||
// Центрируем изображение относительно рамки
|
||
const scaledWidth = img.naturalWidth * baseScale;
|
||
const scaledHeight = img.naturalHeight * baseScale;
|
||
|
||
// Учитываем отступы от рамки до viewport
|
||
const frameLeft = frameRect.left - viewportRect.left;
|
||
const frameTop = frameRect.top - viewportRect.top;
|
||
|
||
setPosition({
|
||
x: frameLeft + (frameWidth - scaledWidth) / 2,
|
||
y: frameTop + (frameHeight - scaledHeight) / 2
|
||
});
|
||
};
|
||
|
||
img.onerror = () => {
|
||
// В случае ошибки загрузки изображения
|
||
console.error('Ошибка загрузки изображения');
|
||
navigate('/');
|
||
};
|
||
|
||
img.src = imageUrl;
|
||
}, [imageUrl, navigate]);
|
||
|
||
const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||
if ('touches' in e) {
|
||
if (e.touches.length === 2) {
|
||
const distance = getDistance(e.touches);
|
||
setInitialTouch({ distance, scale });
|
||
setIsDragging(false);
|
||
} else {
|
||
const touch = e.touches[0];
|
||
setIsDragging(true);
|
||
setDragStart({ x: touch.clientX - position.x, y: touch.clientY - position.y });
|
||
}
|
||
} else {
|
||
setIsDragging(true);
|
||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||
}
|
||
};
|
||
|
||
const handleMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||
if ('touches' in e) {
|
||
if (e.touches.length === 2 && initialTouch) {
|
||
// Масштабирование двумя пальцами
|
||
const distance = getDistance(e.touches);
|
||
const newScale = Math.min(
|
||
Math.max(
|
||
(initialTouch.scale * distance) / initialTouch.distance,
|
||
minScale
|
||
),
|
||
minScale * 3
|
||
);
|
||
handleScale(newScale);
|
||
} else if (isDragging) {
|
||
const touch = e.touches[0];
|
||
handleDrag(touch.clientX, touch.clientY);
|
||
}
|
||
} else if (isDragging) {
|
||
handleDrag(e.clientX, e.clientY);
|
||
}
|
||
};
|
||
|
||
const handleEnd = () => {
|
||
setIsDragging(false);
|
||
setInitialTouch(null);
|
||
};
|
||
|
||
const getDistance = (touches: React.TouchList) => {
|
||
const dx = touches[0].clientX - touches[1].clientX;
|
||
const dy = touches[0].clientY - touches[1].clientY;
|
||
return Math.sqrt(dx * dx + dy * dy);
|
||
};
|
||
|
||
const handleDrag = (clientX: number, clientY: number) => {
|
||
if (!viewportRef.current || !imageRef.current) return;
|
||
|
||
const viewport = viewportRef.current;
|
||
const frame = viewport.querySelector(`.${styles.frame}`);
|
||
if (!frame) return;
|
||
|
||
const viewportRect = viewport.getBoundingClientRect();
|
||
const frameRect = frame.getBoundingClientRect();
|
||
|
||
// Получаем позицию рамки относительно viewport
|
||
const frameLeft = frameRect.left - viewportRect.left;
|
||
const frameTop = frameRect.top - viewportRect.top;
|
||
|
||
// Вычисляем размеры масштабированного изображения
|
||
const scaledWidth = imageRef.current.naturalWidth * scale;
|
||
const scaledHeight = imageRef.current.naturalHeight * scale;
|
||
|
||
// Вычисляем новую позицию
|
||
const newX = clientX - dragStart.x;
|
||
const newY = clientY - dragStart.y;
|
||
|
||
// Ограничиваем перемещение
|
||
const minX = frameLeft - (scaledWidth - frameRect.width);
|
||
const maxX = frameLeft;
|
||
const minY = frameTop - (scaledHeight - frameRect.height);
|
||
const maxY = frameTop;
|
||
|
||
setPosition({
|
||
x: Math.max(minX, Math.min(maxX, newX)),
|
||
y: Math.max(minY, Math.min(maxY, newY))
|
||
});
|
||
};
|
||
|
||
const handleScale = (newScale: number) => {
|
||
if (!viewportRef.current || !imageRef.current) return;
|
||
|
||
const viewport = viewportRef.current;
|
||
const frame = viewport.querySelector(`.${styles.frame}`);
|
||
if (!frame) return;
|
||
|
||
const viewportRect = viewport.getBoundingClientRect();
|
||
const frameRect = frame.getBoundingClientRect();
|
||
|
||
// Получаем позицию рамки относительно viewport
|
||
const frameLeft = frameRect.left - viewportRect.left;
|
||
const frameTop = frameRect.top - viewportRect.top;
|
||
|
||
// Вычисляем центр рамки
|
||
const frameCenterX = frameLeft + frameRect.width / 2;
|
||
const frameCenterY = frameTop + frameRect.height / 2;
|
||
|
||
// Вычисляем текущий центр изображения
|
||
const currentCenterX = position.x + (imageRef.current.naturalWidth * scale) / 2;
|
||
const currentCenterY = position.y + (imageRef.current.naturalHeight * scale) / 2;
|
||
|
||
// Вычисляем смещение для сохранения центра
|
||
const dx = (frameCenterX - currentCenterX) * (newScale / scale - 1);
|
||
const dy = (frameCenterY - currentCenterY) * (newScale / scale - 1);
|
||
|
||
// Вычисляем новые размеры изображения
|
||
const newScaledWidth = imageRef.current.naturalWidth * newScale;
|
||
const newScaledHeight = imageRef.current.naturalHeight * newScale;
|
||
|
||
// Вычисляем новую позицию
|
||
let newX = position.x + dx;
|
||
let newY = position.y + dy;
|
||
|
||
// Ограничиваем позицию
|
||
const minX = frameLeft - (newScaledWidth - frameRect.width);
|
||
const maxX = frameLeft;
|
||
const minY = frameTop - (newScaledHeight - frameRect.height);
|
||
const maxY = frameTop;
|
||
|
||
setScale(newScale);
|
||
setPosition({
|
||
x: Math.max(minX, Math.min(maxX, newX)),
|
||
y: Math.max(minY, Math.min(maxY, newY))
|
||
});
|
||
};
|
||
|
||
const handleZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
handleScale(parseFloat(e.target.value));
|
||
};
|
||
|
||
const createPreview = () => {
|
||
if (!imageRef.current || !viewportRef.current) return null;
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 768;
|
||
canvas.height = 768;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return null;
|
||
|
||
const viewport = viewportRef.current;
|
||
const frame = viewport.querySelector(`.${styles.frame}`);
|
||
if (!frame) return null;
|
||
|
||
const frameRect = frame.getBoundingClientRect();
|
||
const image = imageRef.current;
|
||
|
||
ctx.drawImage(
|
||
image,
|
||
-position.x / scale,
|
||
-position.y / scale,
|
||
frameRect.width / scale,
|
||
frameRect.height / scale,
|
||
0,
|
||
0,
|
||
768,
|
||
768
|
||
);
|
||
|
||
return canvas.toDataURL('image/jpeg', 0.95);
|
||
};
|
||
|
||
const handleConfirm = () => {
|
||
const previewUrl = createPreview();
|
||
if (previewUrl) {
|
||
// Передаем не только URL, но и base64 данные
|
||
// Убираем префикс data:image/jpeg;base64, оставляем только данные
|
||
const imageData = previewUrl.split(',')[1];
|
||
|
||
// Сохраняем данные в localStorage для сохранения между сеансами навигации
|
||
localStorage.setItem('stickerPreviewUrl', previewUrl);
|
||
localStorage.setItem('stickerImageData', imageData);
|
||
|
||
navigate('/', {
|
||
state: {
|
||
previewUrl,
|
||
imageData
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<div className={styles.header}>
|
||
<button className={styles.backButton} onClick={() => navigate('/')}>
|
||
←
|
||
</button>
|
||
<h1 className={styles.title}>Обрезка фото</h1>
|
||
<button className={styles.confirmButton} onClick={handleConfirm}>
|
||
Готово
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.cropContainer}>
|
||
<div className={styles.viewportContainer}>
|
||
<div
|
||
ref={viewportRef}
|
||
className={styles.viewport}
|
||
onTouchStart={handleStart}
|
||
onTouchMove={handleMove}
|
||
onTouchEnd={handleEnd}
|
||
onTouchCancel={handleEnd}
|
||
onMouseDown={handleStart}
|
||
onMouseMove={handleMove}
|
||
onMouseUp={handleEnd}
|
||
onMouseLeave={handleEnd}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
>
|
||
{imageUrl && (
|
||
<div
|
||
ref={wrapperRef}
|
||
className={styles.imageWrapper}
|
||
>
|
||
<img
|
||
ref={imageRef}
|
||
src={imageUrl}
|
||
alt="Crop"
|
||
className={styles.image}
|
||
draggable={false}
|
||
style={{
|
||
transform: `matrix(${scale}, 0, 0, ${scale}, ${position.x}, ${position.y})`
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className={styles.frame} />
|
||
<div className={styles.frameCross} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.controls}>
|
||
<input
|
||
type="range"
|
||
min={minScale}
|
||
max={minScale * 3}
|
||
step="0.01"
|
||
value={scale}
|
||
onChange={handleZoom}
|
||
className={styles.zoomSlider}
|
||
/>
|
||
<div className={styles.hint}>
|
||
Отрегулируйте масштаб и положение фото
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default CropPhoto;
|