/** * Image Editor Modal Component * Предоставляет интерфейс для загрузки, обрезки и редактирования изображений */ class ImageEditor { constructor(options = {}) { this.options = { targetFolder: 'routes', aspectRatio: null, // null = свободная обрезка, или например 16/9 maxWidth: 1200, maxHeight: 800, ...options }; this.modal = null; this.canvas = null; this.ctx = null; this.image = null; this.imageData = null; this.cropBox = null; this.isDragging = false; this.lastMousePos = { x: 0, y: 0 }; this.rotation = 0; this.flipHorizontal = false; this.flipVertical = false; this.onSave = options.onSave || (() => {}); this.onCancel = options.onCancel || (() => {}); this.createModal(); } createModal() { // Создаем модальное окно const modalHTML = `

Редактор изображений

📷

Перетащите изображение сюда или

`; // Добавляем стили if (!document.getElementById('image-editor-styles')) { const styles = document.createElement('style'); styles.id = 'image-editor-styles'; styles.textContent = ` .image-editor-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); display: flex; justify-content: center; align-items: center; z-index: 10000; } .image-editor-modal { background: white; border-radius: 8px; width: 90vw; max-width: 900px; max-height: 90vh; display: flex; flex-direction: column; overflow: hidden; } .image-editor-header { padding: 20px; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; } .image-editor-header h3 { margin: 0; color: #333; } .close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; } .close-btn:hover { color: #333; } .image-editor-body { flex: 1; padding: 20px; overflow: auto; } .upload-area { border: 2px dashed #ccc; border-radius: 8px; padding: 40px; text-align: center; background: #f9f9f9; transition: all 0.3s ease; } .upload-area.dragover { border-color: #007bff; background: #e3f2fd; } .upload-content .upload-icon { font-size: 48px; margin-bottom: 16px; } .upload-content p { margin: 0; color: #666; } .btn-link { background: none; border: none; color: #007bff; text-decoration: underline; cursor: pointer; } .editor-area { display: flex; flex-direction: column; gap: 16px; } .editor-toolbar { display: flex; gap: 8px; justify-content: center; padding: 12px; background: #f8f9fa; border-radius: 4px; } .tool-btn { background: white; border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; cursor: pointer; transition: all 0.2s ease; } .tool-btn:hover { background: #007bff; color: white; border-color: #007bff; } .canvas-container { position: relative; display: flex; justify-content: center; background: #f0f0f0; border-radius: 4px; overflow: hidden; } #editorCanvas { max-width: 100%; max-height: 400px; border: 1px solid #ddd; } .crop-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } .crop-box { position: absolute; border: 2px solid #007bff; background: rgba(0, 123, 255, 0.1); pointer-events: all; cursor: move; } .crop-handle { position: absolute; width: 10px; height: 10px; background: #007bff; border: 1px solid white; border-radius: 2px; } .crop-handle.nw { top: -5px; left: -5px; cursor: nw-resize; } .crop-handle.ne { top: -5px; right: -5px; cursor: ne-resize; } .crop-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; } .crop-handle.se { bottom: -5px; right: -5px; cursor: se-resize; } .crop-handle.n { top: -5px; left: 50%; margin-left: -5px; cursor: n-resize; } .crop-handle.s { bottom: -5px; left: 50%; margin-left: -5px; cursor: s-resize; } .crop-handle.e { top: 50%; right: -5px; margin-top: -5px; cursor: e-resize; } .crop-handle.w { top: 50%; left: -5px; margin-top: -5px; cursor: w-resize; } .image-info { display: flex; justify-content: space-between; padding: 8px; background: #f8f9fa; border-radius: 4px; font-size: 12px; color: #666; } .image-editor-footer { padding: 20px; border-top: 1px solid #ddd; display: flex; gap: 12px; justify-content: flex-end; } .btn { padding: 8px 16px; border-radius: 4px; border: 1px solid; cursor: pointer; font-size: 14px; } .btn-secondary { background: #f8f9fa; color: #333; border-color: #ddd; } .btn-primary { background: #007bff; color: white; border-color: #007bff; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } `; document.head.appendChild(styles); } // Добавляем модальное окно в DOM document.body.insertAdjacentHTML('beforeend', modalHTML); this.modal = document.querySelector('.image-editor-overlay'); this.bindEvents(); } bindEvents() { // Закрытие модального окна this.modal.querySelector('.close-btn').addEventListener('click', () => this.close()); this.modal.querySelector('#cancelBtn').addEventListener('click', () => this.close()); // Клик по overlay для закрытия this.modal.addEventListener('click', (e) => { if (e.target === this.modal) this.close(); }); // Загрузка файла const fileInput = this.modal.querySelector('#fileInput'); const selectFileBtn = this.modal.querySelector('#selectFileBtn'); const uploadArea = this.modal.querySelector('#uploadArea'); selectFileBtn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', (e) => this.handleFileSelect(e)); // Drag & Drop uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); }); uploadArea.addEventListener('dragleave', () => { uploadArea.classList.remove('dragover'); }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('dragover'); const files = e.dataTransfer.files; if (files.length > 0) { this.loadImage(files[0]); } }); // Инструменты редактирования this.modal.querySelector('#rotateLeftBtn').addEventListener('click', () => this.rotate(-90)); this.modal.querySelector('#rotateRightBtn').addEventListener('click', () => this.rotate(90)); this.modal.querySelector('#flipHorizontalBtn').addEventListener('click', () => this.flipHorizontal = !this.flipHorizontal, this.redraw()); this.modal.querySelector('#flipVerticalBtn').addEventListener('click', () => this.flipVertical = !this.flipVertical, this.redraw()); this.modal.querySelector('#resetCropBtn').addEventListener('click', () => this.resetCrop()); // Сохранение this.modal.querySelector('#saveBtn').addEventListener('click', () => this.save()); } handleFileSelect(e) { const file = e.target.files[0]; if (file) { this.loadImage(file); } } async loadImage(file) { if (!file.type.startsWith('image/')) { alert('Пожалуйста, выберите изображение'); return; } const formData = new FormData(); formData.append('image', file); try { const response = await fetch('/api/images/upload-image', { method: 'POST', body: formData }); const result = await response.json(); if (result.success) { this.imageData = result; this.showEditor(); this.loadImageToCanvas(result.tempUrl); } else { alert(result.error || 'Ошибка загрузки изображения'); } } catch (error) { console.error('Upload error:', error); alert('Ошибка загрузки изображения'); } } loadImageToCanvas(imageUrl) { this.image = new Image(); this.image.onload = () => { this.initCanvas(); this.initCropBox(); this.redraw(); this.updateInfo(); this.modal.querySelector('#saveBtn').disabled = false; }; this.image.src = imageUrl; } showEditor() { this.modal.querySelector('#uploadArea').style.display = 'none'; this.modal.querySelector('#editorArea').style.display = 'block'; } initCanvas() { this.canvas = this.modal.querySelector('#editorCanvas'); this.ctx = this.canvas.getContext('2d'); // Вычисляем размеры canvas const containerWidth = 600; // максимальная ширина const containerHeight = 400; // максимальная высота const imageRatio = this.image.width / this.image.height; const containerRatio = containerWidth / containerHeight; if (imageRatio > containerRatio) { this.canvas.width = containerWidth; this.canvas.height = containerWidth / imageRatio; } else { this.canvas.width = containerHeight * imageRatio; this.canvas.height = containerHeight; } this.scaleX = this.canvas.width / this.image.width; this.scaleY = this.canvas.height / this.image.height; } initCropBox() { const overlay = this.modal.querySelector('#cropOverlay'); const cropBox = this.modal.querySelector('#cropBox'); // Устанавливаем размеры overlay как у canvas const canvasRect = this.canvas.getBoundingClientRect(); const containerRect = this.canvas.parentElement.getBoundingClientRect(); overlay.style.width = `${canvasRect.width}px`; overlay.style.height = `${canvasRect.height}px`; overlay.style.left = `${canvasRect.left - containerRect.left}px`; overlay.style.top = `${canvasRect.top - containerRect.top}px`; // Инициализируем crop box (50% от центра) const boxWidth = canvasRect.width * 0.6; const boxHeight = canvasRect.height * 0.6; const boxLeft = (canvasRect.width - boxWidth) / 2; const boxTop = (canvasRect.height - boxHeight) / 2; cropBox.style.width = `${boxWidth}px`; cropBox.style.height = `${boxHeight}px`; cropBox.style.left = `${boxLeft}px`; cropBox.style.top = `${boxTop}px`; this.cropBox = { x: boxLeft / canvasRect.width, y: boxTop / canvasRect.height, width: boxWidth / canvasRect.width, height: boxHeight / canvasRect.height }; this.bindCropEvents(cropBox, overlay); } bindCropEvents(cropBox, overlay) { let resizing = false; let moving = false; let startPos = { x: 0, y: 0 }; let startBox = {}; // Обработчик для перемещения cropBox.addEventListener('mousedown', (e) => { if (e.target === cropBox) { moving = true; startPos = { x: e.clientX, y: e.clientY }; startBox = { ...this.cropBox }; e.preventDefault(); } }); // Обработчик для изменения размера const handles = cropBox.querySelectorAll('.crop-handle'); handles.forEach(handle => { handle.addEventListener('mousedown', (e) => { resizing = handle.className.replace('crop-handle ', ''); startPos = { x: e.clientX, y: e.clientY }; startBox = { ...this.cropBox }; e.preventDefault(); e.stopPropagation(); }); }); // Обработчики движения и отпускания мыши document.addEventListener('mousemove', (e) => { if (moving || resizing) { this.updateCropBox(e, startPos, startBox, moving ? 'move' : resizing, overlay); } }); document.addEventListener('mouseup', () => { moving = false; resizing = false; }); } updateCropBox(e, startPos, startBox, action, overlay) { const overlayRect = overlay.getBoundingClientRect(); const deltaX = (e.clientX - startPos.x) / overlayRect.width; const deltaY = (e.clientY - startPos.y) / overlayRect.height; let newBox = { ...startBox }; if (action === 'move') { newBox.x = Math.max(0, Math.min(1 - startBox.width, startBox.x + deltaX)); newBox.y = Math.max(0, Math.min(1 - startBox.height, startBox.y + deltaY)); } else { // Изменение размера в зависимости от handle if (action.includes('n')) newBox.y += deltaY, newBox.height -= deltaY; if (action.includes('s')) newBox.height += deltaY; if (action.includes('w')) newBox.x += deltaX, newBox.width -= deltaX; if (action.includes('e')) newBox.width += deltaX; // Ограничиваем минимальные размеры if (newBox.width < 0.1) newBox.width = 0.1; if (newBox.height < 0.1) newBox.height = 0.1; // Ограничиваем границы if (newBox.x < 0) newBox.x = 0; if (newBox.y < 0) newBox.y = 0; if (newBox.x + newBox.width > 1) newBox.width = 1 - newBox.x; if (newBox.y + newBox.height > 1) newBox.height = 1 - newBox.y; } this.cropBox = newBox; this.updateCropBoxDisplay(overlay); this.updateInfo(); } updateCropBoxDisplay(overlay) { const cropBoxElement = this.modal.querySelector('#cropBox'); const overlayRect = overlay.getBoundingClientRect(); cropBoxElement.style.left = `${this.cropBox.x * overlayRect.width}px`; cropBoxElement.style.top = `${this.cropBox.y * overlayRect.height}px`; cropBoxElement.style.width = `${this.cropBox.width * overlayRect.width}px`; cropBoxElement.style.height = `${this.cropBox.height * overlayRect.height}px`; } rotate(degrees) { this.rotation = (this.rotation + degrees) % 360; this.redraw(); this.updateInfo(); } redraw() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.save(); // Перемещаем к центру this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2); // Поворот this.ctx.rotate(this.rotation * Math.PI / 180); // Отражение this.ctx.scale(this.flipHorizontal ? -1 : 1, this.flipVertical ? -1 : 1); // Рисуем изображение this.ctx.drawImage( this.image, -this.canvas.width / 2, -this.canvas.height / 2, this.canvas.width, this.canvas.height ); this.ctx.restore(); } resetCrop() { // Сброс crop box к исходному состоянию this.cropBox = { x: 0.2, y: 0.2, width: 0.6, height: 0.6 }; this.updateCropBoxDisplay(this.modal.querySelector('#cropOverlay')); this.updateInfo(); } updateInfo() { const imageInfo = this.modal.querySelector('#imageInfo'); const cropInfo = this.modal.querySelector('#cropInfo'); imageInfo.textContent = `Размер: ${this.image.width}x${this.image.height}`; const cropWidth = Math.round(this.cropBox.width * this.image.width); const cropHeight = Math.round(this.cropBox.height * this.image.height); cropInfo.textContent = `Обрезка: ${cropWidth}x${cropHeight}`; } async save() { const saveBtn = this.modal.querySelector('#saveBtn'); saveBtn.disabled = true; saveBtn.textContent = 'Сохранение...'; try { const cropData = { x: this.cropBox.x * this.image.width, y: this.cropBox.y * this.image.height, width: this.cropBox.width * this.image.width, height: this.cropBox.height * this.image.height }; const response = await fetch('/api/images/process-image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tempId: this.imageData.tempId, rotation: this.rotation, flipHorizontal: this.flipHorizontal, flipVertical: this.flipVertical, cropData, targetFolder: this.options.targetFolder }) }); const result = await response.json(); if (result.success) { this.onSave(result.url); this.close(); } else { alert(result.error || 'Ошибка сохранения'); saveBtn.disabled = false; saveBtn.textContent = 'Сохранить'; } } catch (error) { console.error('Save error:', error); alert('Ошибка сохранения изображения'); saveBtn.disabled = false; saveBtn.textContent = 'Сохранить'; } } close() { // Удаляем временный файл if (this.imageData && this.imageData.tempId) { fetch(`/api/images/temp-image/${this.imageData.tempId}`, { method: 'DELETE' }).catch(console.error); } this.onCancel(); if (this.modal) { this.modal.remove(); } } show() { if (this.modal) { this.modal.style.display = 'flex'; } } } // Глобально доступная функция для открытия редактора window.openImageEditor = function(options = {}) { return new Promise((resolve, reject) => { const editor = new ImageEditor({ ...options, onSave: (url) => resolve(url), onCancel: () => reject(new Error('Canceled')) }); editor.show(); }); };