Создай приложение которое позволяет открыть изображение с устройства и изменить его размеры сделать кроп перевернуть развернуть слева направо и справа на лево
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Редактор изображений</title>
<script src="https://app.aisearch.ru/static/tailwind/tailwind.js"></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"
/>
<style>
/ Кроп область будет внутри контейнера с overflow hidden /
#crop-area {
overflow: hidden;
position: relative;
display: inline-block;
touch-action: none;
user-select: none;
max-width: 100%;
max-height: 500px;
border: 2px dashed #ccc;
margin-top: 0.5rem;
}
#crop-image {
display: block;
max-width: none;
user-select: none;
cursor: grab;
transform-origin: top left;
position: relative;
left: 0;
top: 0;
touch-action: none;
}
/ Кроп окно - рамка и перетаскивание /
#crop-window {
position: absolute;
border: 2px solid #09f;
box-sizing: border-box;
cursor: move;
background: rgba(0, 0, 255, 0.1);
/ минимальный размер 50х50 /
min-width: 50px;
min-height: 50px;
user-select: none;
touch-action: none;
}
.handle {
width: 12px;
height: 12px;
background: #09f;
position: absolute;
border-radius: 50%;
user-select: none;
touch-action: none;
}
.handle.nw {
top: -6px;
left: -6px;
cursor: nwse-resize;
}
.handle.ne {
top: -6px;
right: -6px;
cursor: nesw-resize;
}
.handle.sw {
bottom: -6px;
left: -6px;
cursor: nesw-resize;
}
.handle.se {
bottom: -6px;
right: -6px;
cursor: nwse-resize;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen p-4 flex flex-col items-center font-sans">
<h1 class="text-2xl font-bold mb-6">Редактор изображений</h1>
<div class="w-full max-w-4xl flex flex-col md:flex-row gap-6">
<div class="md:w-1/2 flex flex-col items-center">
<label
for="file-input"
class="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded mb-4 inline-flex items-center gap-2"
><i class="fas fa-upload"></i> Загрузить изображение</label
>
<input type="file" id="file-input" accept="image/*" class="hidden" />
<div id="crop-area" class="w-full max-w-full h-auto rounded bg-white shadow-md">
<img
id="crop-image"
alt="Изображение для редактирования, загруженное пользователем"
draggable="false"
src=""
style="transform-origin: top left"
/>
<div id="crop-window" aria-label="Область кропа" tabindex="0" role="region" aria-describedby="crop-controls" hidden>
<div class="handle nw" data-handle="nw"></div>
<div class="handle ne" data-handle="ne"></div>
<div class="handle sw" data-handle="sw"></div>
<div class="handle se" data-handle="se"></div>
</div>
</div>
</div>
<div class="md:w-1/2 bg-white p-4 rounded shadow-md flex flex-col gap-5">
<div>
<label for="resize-width" class="block font-semibold mb-1">Ширина (px)</label>
<input
type="number"
id="resize-width"
min="1"
class="border border-gray-300 rounded px-2 py-1 w-full"
aria-describedby="resize-desc"
placeholder="Авто"
/>
<p id="resize-desc" class="text-sm text-gray-500 mt-1">Введите для изменения размера, оставьте пустым для авто</p>
</div>
<div>
<label for="resize-height" class="block font-semibold mb-1">Высота (px)</label>
<input
type="number"
id="resize-height"
min="1"
class="border border-gray-300 rounded px-2 py-1 w-full"
placeholder="Авто"
/>
</div>
<button
id="apply-resize"
class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded w-full font-semibold"
disabled
>
Изменить размер
</button>
<div class="pt-4 border-t border-gray-200">
<h2 class="text-lg font-semibold mb-2">Операции над изображением</h2>
<div class="flex flex-wrap gap-3">
<button
id="flip-horizontal"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex items-center gap-2"
disabled
aria-pressed="false"
>
<i class="fas fa-arrows-alt-h"></i> Перевернуть слева направо
</button>
<button
id="flip-vertical"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex items-center gap-2"
disabled
aria-pressed="false"
>
<i class="fas fa-arrows-alt-v"></i> Перевернуть сверху вниз
</button>
<button
id="rotate-left"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex items-center gap-2"
disabled
>
<i class="fas fa-undo"></i> Повернуть влево (90°)
</button>
<button
id="rotate-right"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex items-center gap-2"
disabled
>
<i class="fas fa-redo"></i> Повернуть вправо (90°)
</button>
<button
id="crop-btn"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded flex items-center gap-2"
aria-pressed="false"
disabled
>
<i class="fas fa-crop"></i> Кропнуть изображение
</button>
<button
id="reset-btn"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded flex items-center gap-2"
disabled
>
<i class="fas fa-sync-alt"></i> Сбросить все изменения
</button>
</div>
</div>
<div>
<h2 class="text-lg font-semibold mb-2">Результат</h2>
<canvas id="canvas" class="border border-gray-300 rounded w-full max-h-[500px] object-contain bg-white" aria-label="Результирующее изображение"></canvas>
</div>
</div>
</div>
<script>
(() => {
const fileInput = document.getElementById("file-input");
const cropImage = document.getElementById("crop-image");
const cropArea = document.getElementById("crop-area");
const cropWindow = document.getElementById("crop-window");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const resizeWidthInput = document.getElementById("resize-width");
const resizeHeightInput = document.getElementById("resize-height");
const applyResizeBtn = document.getElementById("apply-resize");
const flipHBtn = document.getElementById("flip-horizontal");
const flipVBtn = document.getElementById("flip-vertical");
const rotateLeftBtn = document.getElementById("rotate-left");
const rotateRightBtn = document.getElementById("rotate-right");
const cropBtn = document.getElementById("crop-btn");
const resetBtn = document.getElementById("reset-btn");
let image = new Image();
let originalImage = null;
// Хранение состояния трансформаций
let state = {
scaleX: 1,
scaleY: 1,
rotation: 0, // градусы: 0, 90, 180, 270
width: 0,
height: 0,
posX: 0,
posY: 0,
cropping: false,
};
// Кроп окно параметры (в пределах cropArea)
let cropRect = { left: 50, top: 50, width: 200, height: 150 };
let isDraggingCrop = false;
let cropDragOffset = { x: 0, y: 0 };
let isResizingCrop = false;
let currentResizeHandle = null;
function updateUIOnImageLoad() {
applyResizeBtn.disabled = false;
flipHBtn.disabled = false;
flipVBtn.disabled = false;
rotateLeftBtn.disabled = false;
rotateRightBtn.disabled = false;
cropBtn.disabled = false;
resetBtn.disabled = false;
resizeWidthInput.value = image.naturalWidth;
resizeHeightInput.value = image.naturalHeight;
cropWindow.hidden = true;
state.cropping = false;
}
function resetState() {
state = {
scaleX: 1,
scaleY: 1,
rotation: 0,
width: image.naturalWidth,
height: image.naturalHeight,
posX: 0,
posY: 0,
cropping: false,
};
cropRect = {
left: 50,
top: 50,
width: Math.min(200, state.width),
height: Math.min(150, state.height),
};
updateCropWindow();
updateImageTransform();
updateCanvas();
}
// Обновление CSS transform по текущему state
function updateImageTransform() {
const rotate = state.rotation % 360;
const scaleX = state.scaleX;
const scaleY = state.scaleY;
const t = translate(${state.posX}px, ${state.posY}px) rotate(${rotate}deg) scale(${scaleX}, ${scaleY});
cropImage.style.transform = t;
}
// Обновление размеров cropArea и cropWindow
function updateCropWindow() {
cropWindow.style.left = cropRect.left + "px";
cropWindow.style.top = cropRect.top + "px";
cropWindow.style.width = cropRect.width + "px";
cropWindow.style.height = cropRect.height + "px";
}
// Показ и скрытие cropWindow
function setCroppingMode(enabled) {
state.cropping = enabled;
cropWindow.hidden = !enabled;
cropBtn.setAttribute("aria-pressed", enabled);
if (enabled) {
// Установить crop размер и позицию в центре изображения
const imgWidth = cropImage.width;
const imgHeight = cropImage.height;
cropRect = {
left: Math.max(0, (imgWidth - 200) / 2),
top: Math.max(0, (imgHeight - 150) / 2),
width: Math.min(200, imgWidth),
height: Math.min(150, imgHeight),
};
updateCropWindow();
}
}
// Отрисовать преобразованное изображение на канвасе (с учетом кропа если выбран)
function updateCanvas() {
if (!image.src) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
let sourceX = 0,
sourceY = 0,
sourceWidth = image.naturalWidth,
sourceHeight = image.naturalHeight;
if (state.cropping) {
// Перевести cropRect с CSS в координаты исходного изображения
// cropImage может быть масштабирован и повернут, надо брать ratio
// т.к. cropArea ограничен размерами cropImage она должна иметь одинаковый размер с отображаемым изображением
// Отношение натурального изображения к размеру отображаемого (учитывая масштаб и поворот?)
// В нашем случае cropImage отображается с CSS transform
// Для упрощения примем, что cropImage.width и cropImage.height уже изменены для current размерного state
// Для точности:
// Для поворота 0 или 180 cropRect можно брать как есть с поправкой на масштаб. При 90/270 меняются оси.
// Вычислим размер и позицию crop-window относительно изображения без transform:
const imgRect = cropImage.getBoundingClientRect();
const cropAreaRect = cropArea.getBoundingClientRect();
// Кроп относительно самого изображения (в пикселях отображения)
let cropLeft = cropRect.left;
let cropTop = cropRect.top;
let cropWidth = cropRect.width;
let cropHeight = cropRect.height;
// В зависимости от поворота вычислим исходные координаты кропа
let sx, sy, sw, sh;
switch (state.rotation % 360) {
case 0:
sx = (cropLeft / cropImage.width) * image.naturalWidth;
sy = (cropTop / cropImage.height) * image.naturalHeight;
sw = (cropWidth / cropImage.width) * image.naturalWidth;
sh = (cropHeight / cropImage.height) * image.naturalHeight;
break;
case 90:
case -270:
// Поворот 90 - X и Y меняется местами
sx =
((cropImage.height - (cropTop + cropHeight)) /
cropImage.height) *
image.naturalWidth;
sy = (cropLeft / cropImage.width) * image.naturalHeight;
sw = (cropHeight / cropImage.height) * image.naturalWidth;
sh = (cropWidth / cropImage.width) * image.naturalHeight;
break;
case 180:
case -180:
sx =
((cropImage.width - (cropLeft + cropWidth)) /
cropImage.width) *
image.naturalWidth;
sy =
((cropImage.height - (cropTop + cropHeight)) /
cropImage.height) *
image.naturalHeight;
sw = (cropWidth / cropImage.width) * image.naturalWidth;
sh = (cropHeight / cropImage.height) * image.naturalHeight;
break;
case 270:
case -90:
sx = (cropTop / cropImage.height) * image.naturalWidth;
sy =
((cropImage.width - (cropLeft + cropWidth)) /
cropImage.width) *
image.naturalHeight;
sw = (cropHeight / cropImage.height) * image.naturalWidth;
sh = (cropWidth / cropImage.width) * image.naturalHeight;
break;
default:
sx = 0;
sy = 0;
sw = image.naturalWidth;
sh = image.naturalHeight;
}
sourceX = Math.max(0, Math.floor(sx));
sourceY = Math.max(0, Math.floor(sy));
sourceWidth = Math.min(image.naturalWidth - sourceX, Math.floor(sw));
sourceHeight = Math.min(image.naturalHeight - sourceY, Math.floor(sh));
}
// Рассчитаем canvas размеры согласно resize inputs.
// Если нет размеров - берем исходные размеры нарезанной области
let targetW =
parseInt(resizeWidthInput.value) || sourceWidth || image.naturalWidth;
let targetH =
parseInt(resizeHeightInput.value) || sourceHeight || image.naturalHeight;
// Поддержка пропорций. Если ввели только ширину, вычислим высоту
if (resizeWidthInput.value && !resizeHeightInput.value) {
targetH = Math.round(
(targetW / sourceWidth) * sourceHeight
);
resizeHeightInput.value = targetH;
} else if (!resizeWidthInput.value && resizeHeightInput.value) {
targetW = Math.round(
(targetH / sourceHeight) * sourceWidth
);
resizeWidthInput.value = targetW;
}
canvas.width = targetW;
canvas.height = targetH;
// Сброс трансформаций canvas перед рисованием
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Переносим вращение и перевороты на canvas:
ctx.save();
// Перевороты по осям
let scaleX = state.scaleX;
let scaleY = state.scaleY;
// Центр canvas для поворота
const cx = canvas.width / 2;
const cy = canvas.height / 2;
ctx.translate(cx, cy);
ctx.rotate((state.rotation * Math.PI) / 180);
ctx.scale(scaleX, scaleY);
// После вращения рисуем изображение так, чтобы оно центрировалось
// Размеры могут поменяться местами при 90 и 270
let drawWidth = targetW;
let drawHeight = targetH;
if (
state.rotation % 180 !== 0 &&
state.rotation % 360 !== 0
) {
// swap width/height on rotation 90/270
[drawWidth, drawHeight] = [drawHeight, drawWidth];
}
ctx.drawImage(
image,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
-drawWidth / 2,
-drawHeight / 2,
drawWidth,
drawHeight
);
ctx.restore();
}
// Обновляем размеры изображения в cropArea без изменения state (только DOM)
function updateCropImageDimensions(width, height) {
cropImage.style.width = width + "px";
cropImage.style.height = height + "px";
}
// При загрузке файла
fileInput.addEventListener("change", (e) => {
if (!e.target.files || e.target.files.length === 0) return;
const file = e.target.files[0];
if (!file.type.startsWith("image/")) {
alert("Пожалуйста, выберите файл с изображением");
return;
}
const url = URL.createObjectURL(file);
image = new Image();
image.onload = () => {
originalImage = image;
cropImage.src = url;
// Обновим размеры cropImage в cropArea
cropImage.style.width = image.naturalWidth + "px";
cropImage.style.height = image.naturalHeight + "px";
state.width = image.naturalWidth;
state.height = image.naturalHeight;
state.scaleX = 1;
state.scaleY = 1;
state.rotation = 0;
state.posX = 0;
state.posY = 0;
updateImageTransform();
updateUIOnImageLoad();
resetState();
updateCanvas();
};
image.onerror = () => alert("Ошибка загрузки изображения");
image.src = url;
});
applyResizeBtn.addEventListener("click", () => {
if (!image.src) return;
const w = parseInt(resizeWidthInput.value);
const h = parseInt(resizeHeightInput.value);
if (
(w && w < 1) ||
(h && h < 1)
) {
alert("Ширина и высота должны быть больше 0");
return;
}
// Реализуем изменение размеров в отображении картинки (cropImage)
if (w && h) {
updateCropImageDimensions(w, h);
state.width = w;
state.height = h;
// При изменении размера сбрасываем позицию и трансформации
state.posX = 0;
state.posY = 0;
updateImageTransform();
updateCanvas();
}
});
flipHBtn.addEventListener("click", () => {
if (!image.src) return;
state.scaleX *= -1;
updateImageTransform();
updateCanvas();
flipHBtn.setAttribute("aria-pressed", state.scaleX === -1 ? "true" : "false");
});
flipVBtn.addEventListener("click", () => {
if (!image.src) return;
state.scaleY *= -1;
updateImageTransform();
updateCanvas();
flipVBtn.setAttribute("aria-pressed", state.scaleY === -1 ? "true" : "false");
});
rotateLeftBtn.addEventListener("click", () => {
if (!image.src) return;
state.rotation = (state.rotation - 90) % 360;
updateImageTransform();
updateCanvas();
});
rotateRightBtn.addEventListener("click", () => {
if (!image.src) return;
state.rotation = (state.rotation + 90) % 360;
updateImageTransform();
updateCanvas();
});
cropBtn.addEventListener("click", () => {
if (!image.src) return;
setCroppingMode(!state.cropping);
if (!state.cropping) {
updateCanvas();
}
});
resetBtn.addEventListener("click", () => {
if (!image.src) return;
resizeWidthInput.value = image.naturalWidth;
resizeHeightInput.value = image.naturalHeight;
cropImage.style.width = image.naturalWidth + "px";
cropImage.style.height = image.naturalHeight + "px";
state.scaleX = 1;
state.scaleY = 1;
state.rotation = 0;
state.posX = 0;
state.posY = 0;
setCroppingMode(false);
updateImageTransform();
updateCanvas();
});
// Перемещение cropWindow и изменение размеров
cropWindow.addEventListener("mousedown", (e) => {
if (e.target.classList.contains("handle")) return; // обрабатывается ниже
e.preventDefault();
isDraggingCrop = true;
cropDragOffset.x = e.clientX - cropWindow.offsetLeft;
cropDragOffset.y = e.clientY - cropWindow.offsetTop;
});
window.addEventListener("mouseup", (e) => {
isDraggingCrop = false;
isResizingCrop = false;
currentResizeHandle = null;
});
window.addEventListener("mousemove", (e) => {
if (!state.cropping) return;
if (isDraggingCrop) {
e.preventDefault();
// Перемещаем кроп, не выходя за пределы cropArea
let newLeft = e.clientX - cropArea.getBoundingClientRect().left - cropDragOffset.x;
let newTop = e.clientY - cropArea.getBoundingClientRect().top - cropDragOffset.y;
newLeft = Math.max(0, Math.min(newLeft, cropImage.width - cropRect.width));
newTop = Math.max(0, Math.min(newTop, cropImage.height - cropRect.height));
cropRect.left = newLeft;
cropRect.top = newTop;
updateCropWindow();
} else if (isResizingCrop && currentResizeHandle) {
e.preventDefault();
const bounds = cropArea.getBoundingClientRect();
const mouseX = e.clientX - bounds.left;
const mouseY = e.clientY - bounds.top;
// Зависит от текущего handle изменяем cropRect
switch (currentResizeHandle) {
case "nw":
{
let newLeft = Math.min(mouseX, cropRect.left + cropRect.width - 50);
let newTop = Math.min(mouseY, cropRect.top + cropRect.height - 50);
const dx = newLeft - cropRect.left;
const dy = newTop - cropRect.top;
cropRect.left = newLeft;
cropRect.top = newTop;
cropRect.width -= dx;
cropRect.height -= dy;
}
break;
case "ne":
{
let newRight = Math.max(mouseX, cropRect.left + 50);
let newTop = Math.min(mouseY, cropRect.top + cropRect.height - 50);
const dWidth = newRight - (cropRect.left);
const dHeight = cropRect.top - newTop + cropRect.height;
if (dWidth >= 50) cropRect.width = dWidth;
if (dHeight >= 50) {
cropRect.top = newTop;
cropRect.height = dHeight;
}
}
break;
case "sw":
{
let newLeft = Math.min(mouseX, cropRect.left + cropRect.width - 50);
let newBottom = Math.max(mouseY, cropRect.top + 50);
const dWidth = cropRect.left - newLeft + cropRect.width;
const dHeight = newBottom - cropRect.top;
if (dWidth >= 50) {
cropRect.left = newLeft;
cropRect.width = dWidth;
}
if (dHeight >= 50) cropRect.height = dHeight;
}
break;
case "se":
{
let newRight = Math.max(mouseX, cropRect.left + 50);
let newBottom = Math.max(mouseY, cropRect.top + 50);
const dWidth = newRight - cropRect.left;
const dHeight = newBottom - cropRect.top;
if (dWidth >= 50) cropRect.width = dWidth;
if (dHeight >= 50) cropRect.height = dHeight;
}
break;
}
// Ограничения по краям
cropRect.left = Math.max(0, Math.min(cropRect.left, cropImage.width - cropRect.width));
cropRect.top = Math.max(0, Math.min(cropRect.top, cropImage.height - cropRect.height));
cropRect.width = Math.min(cropRect.width, cropImage.width - cropRect.left);
cropRect.height = Math.min(cropRect.height, cropImage.height - cropRect.top);
updateCropWindow();
}
});
// Поиск начала ресайза при mousedown на ручках
cropWindow.querySelectorAll(".handle").forEach((handle) => {
handle.addEventListener("mousedown", (e) => {
e.preventDefault();
isResizingCrop = true;
currentResizeHandle = handle.dataset.handle;
});
});
// Драг изображения (двигать внутри cropArea)
let isDraggingImage = false;
let dragStart = { x: 0, y: 0 };
cropImage.addEventListener("mousedown", (e) => {
if (state.cropping) return; // при кропе запрещаем двигать
e.preventDefault();
isDraggingImage = true;
dragStart.x = e.clientX;
dragStart.y = e.clientY;
});
window.addEventListener("mouseup", () => {
isDraggingImage = false;
});
window.addEventListener("mousemove", (e) => {
if (!isDraggingImage) return;
e.preventDefault();
let dx = e.clientX - dragStart.x;
let dy = e.clientY - dragStart.y;
dragStart.x = e.clientX;
dragStart.y = e.clientY;
// Ограничения движения:
// Не даем выйти за cropArea с учетом размеров изображения
let newX = state.posX + dx;
let newY = state.posY + dy;
let maxX = 0;
let maxY = 0;
let minX = cropArea.clientWidth - cropImage.width;
let minY = cropArea.clientHeight - cropImage.height;
newX = Math.min(maxX, Math.max(minX, newX));
newY = Math.min(maxY, Math.max(minY, newY));
state.posX = newX;
state.posY = newY;
updateImageTransform();
});
// Запрет контекстного меню при долгом тапе на изображении
cropImage.addEventListener("contextmenu", (e) => e.preventDefault());
})();
</script>
</body>
</html>