/** * avatarCropperHandler.js * Компонент для обрезки аватара с требованиями FaceID * * Требования для FaceID: * - Лицо должно занимать 70-80% кадра * - Центрирование лица в кадре * - Минимальный размер: 600x600px * - Соотношение сторон: 1:1 (квадрат) * - Формат: JPEG (оптимизированный для распознавания) * * Зависимости: Cropper.js (https://github.com/fengyuanchen/cropperjs) */ const DEFAULT_CONFIG = { // Селекторы fileInputSelector: 'input[type="file"][name="avatar"]', modalId: 'avatarCropperModal', previewImageId: 'avatarPreview', placeholderId: 'avatarPlaceholder', // Настройки Cropper.js cropperOptions: { aspectRatio: 3 / 4, // Портретное фото 3:4 viewMode: 2, // Ограничить canvas областью контейнера dragMode: 'move', // Перемещение изображения autoCropArea: 0.8, // Начальный размер области обрезки (80%) restore: false, guides: true, center: true, highlight: true, cropBoxMovable: true, cropBoxResizable: true, toggleDragModeOnDblclick: false, minCropBoxWidth: 200, minCropBoxHeight: 266, // 200 * 4/3 для сохранения пропорций }, // Требования для FaceID minOutputSize: 600, // Минимальный размер выходного изображения (по меньшей стороне) outputWidth: 600, // Ширина выходного изображения (3:4) outputHeight: 800, // Высота выходного изображения (3:4) outputQuality: 0.92, // Качество JPEG (0.0 - 1.0) outputFormat: 'image/jpeg', // Формат выходного файла // Тексты для UI texts: { modalTitle: 'Обрезка фото для FaceID', instructions: `
Требования для системы распознавания лиц:
`, cropButton: 'Применить обрезку', cancelButton: 'Отмена', errorTooSmall: 'Изображение слишком маленькое. Минимальный размер: ', errorInvalidFormat: 'Неподдерживаемый формат. Используйте JPG, PNG или GIF.', errorFileSize: 'Файл слишком большой. Максимум 10 МБ.', } }; /** * Создает и управляет модальным окном для обрезки аватара */ export class AvatarCropper { constructor(config = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; this.cropper = null; this.currentFile = null; this.croppedBlob = null; this.init(); } /** * Инициализация компонента */ init() { this.createModal(); this.attachEventListeners(); } /** * Создает модальное окно для cropper */ createModal() { // Проверяем, не существует ли уже модальное окно if (document.getElementById(this.config.modalId)) { return; } const modalHtml = ` `; document.body.insertAdjacentHTML('beforeend', modalHtml); } /** * Навешивает обработчики событий */ attachEventListeners() { // Обработчик выбора файла const fileInput = document.querySelector(this.config.fileInputSelector); if (fileInput) { fileInput.addEventListener('change', (e) => this.handleFileSelect(e)); } // Обработчик кнопки "Применить" const applyBtn = document.getElementById('cropperApplyBtn'); if (applyBtn) { applyBtn.addEventListener('click', () => this.applyCrop()); } // Обработчик закрытия модального окна const modal = document.getElementById(this.config.modalId); if (modal) { modal.addEventListener('hidden.bs.modal', () => this.destroyCropper()); } // Обработчик изменения области обрезки (для обновления индикатора размера) const cropperImage = document.getElementById('cropperImage'); if (cropperImage) { cropperImage.addEventListener('crop', (e) => this.updateSizeIndicator(e.detail)); } // Обработчик переключения контура человека const toggleOverlay = document.getElementById('toggleHumanOverlay'); if (toggleOverlay) { toggleOverlay.addEventListener('change', (e) => this.toggleHumanOverlay(e.target.checked)); } } /** * Обработка выбора файла */ async handleFileSelect(event) { const file = event.target.files[0]; if (!file) return; // Валидация формата if (!file.type.match(/^image\/(jpeg|jpg|png|gif)$/i)) { alert(this.config.texts.errorInvalidFormat); event.target.value = ''; return; } // Валидация размера (10 МБ) if (file.size > 10 * 1024 * 1024) { alert(this.config.texts.errorFileSize); event.target.value = ''; return; } this.currentFile = file; // Проверяем размер изображения const isValidSize = await this.validateImageSize(file); if (!isValidSize) { event.target.value = ''; return; } // Открываем cropper this.openCropper(file); } /** * Проверяет размер изображения */ validateImageSize(file) { return new Promise((resolve) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); const minSize = this.config.minOutputSize; if (img.width < minSize || img.height < minSize) { alert( `${this.config.texts.errorTooSmall}${minSize}x${minSize}px\n` + `Ваше изображение: ${img.width}x${img.height}px` ); resolve(false); } else { resolve(true); } }; img.onerror = () => { URL.revokeObjectURL(url); alert('Не удалось загрузить изображение'); resolve(false); }; img.src = url; }); } /** * Открывает модальное окно с cropper */ openCropper(file) { const reader = new FileReader(); reader.onload = (e) => { const cropperImage = document.getElementById('cropperImage'); cropperImage.src = e.target.result; // Показываем модальное окно const modal = new bootstrap.Modal(document.getElementById(this.config.modalId)); modal.show(); // Инициализируем Cropper.js после отображения модального окна setTimeout(() => { this.initCropper(cropperImage); }, 200); }; reader.readAsDataURL(file); } /** * Инициализирует Cropper.js */ initCropper(imageElement) { // Уничтожаем предыдущий экземпляр, если есть if (this.cropper) { this.cropper.destroy(); } // Создаем новый экземпляр Cropper this.cropper = new Cropper(imageElement, { ...this.config.cropperOptions, ready: () => { console.log('[AvatarCropper] Cropper ready'); this.updateSizeIndicator(); this.updateOverlayPosition(); this.showHumanOverlay(); }, crop: (event) => { this.updateSizeIndicator(event.detail); this.updateOverlayPosition(); }, cropstart: () => { this.updateOverlayPosition(); }, cropmove: () => { this.updateOverlayPosition(); }, cropend: () => { this.updateOverlayPosition(); }, zoom: () => { this.updateOverlayPosition(); } }); } /** * Показывает контур человека */ showHumanOverlay() { const overlay = document.getElementById('humanOverlay'); const toggle = document.getElementById('toggleHumanOverlay'); if (overlay && toggle && toggle.checked) { overlay.style.display = 'block'; } } /** * Переключает видимость контура человека */ toggleHumanOverlay(show) { const overlay = document.getElementById('humanOverlay'); if (overlay) { overlay.style.display = show ? 'block' : 'none'; } } /** * Обновляет позицию оверлея в соответствии с crop box */ updateOverlayPosition() { if (!this.cropper) return; const overlay = document.getElementById('humanOverlay'); if (!overlay) return; const cropBoxData = this.cropper.getCropBoxData(); const containerData = this.cropper.getContainerData(); // Позиционируем SVG оверлей точно над crop box overlay.style.left = cropBoxData.left + 'px'; overlay.style.top = cropBoxData.top + 'px'; overlay.style.width = cropBoxData.width + 'px'; overlay.style.height = cropBoxData.height + 'px'; } /** * Обновляет индикатор размера обрезанного изображения */ updateSizeIndicator(detail) { if (!this.cropper) return; const data = detail || this.cropper.getData(); const width = Math.round(data.width); const height = Math.round(data.height); const indicator = document.getElementById('cropperSizeIndicator'); if (indicator) { const sizeSpan = indicator.querySelector('.fw-bold'); if (sizeSpan) { const minSize = this.config.minOutputSize; const isValid = width >= minSize && height >= minSize; sizeSpan.textContent = `${width}x${height}px`; sizeSpan.className = isValid ? 'fw-bold text-success' : 'fw-bold text-danger'; if (!isValid) { sizeSpan.textContent += ` (мин. ${minSize}x${minSize}px)`; } } } } /** * Применяет обрезку и закрывает модальное окно */ async applyCrop() { if (!this.cropper) return; const applyBtn = document.getElementById('cropperApplyBtn'); const originalText = applyBtn.innerHTML; try { // Показываем индикатор загрузки applyBtn.disabled = true; applyBtn.innerHTML = 'Обработка...'; // Получаем обрезанное изображение в пропорциях 3:4 const canvas = this.cropper.getCroppedCanvas({ width: this.config.outputWidth, height: this.config.outputHeight, imageSmoothingEnabled: true, imageSmoothingQuality: 'high', }); // Конвертируем в Blob this.croppedBlob = await new Promise((resolve) => { canvas.toBlob( (blob) => resolve(blob), this.config.outputFormat, this.config.outputQuality ); }); // Создаем File объект из Blob const croppedFile = new File( [this.croppedBlob], this.currentFile.name, { type: this.config.outputFormat } ); // Обновляем превью this.updatePreview(canvas); // Обновляем input файла this.updateFileInput(croppedFile); // Закрываем модальное окно const modal = bootstrap.Modal.getInstance(document.getElementById(this.config.modalId)); modal.hide(); } catch (error) { console.error('[AvatarCropper] Error applying crop:', error); alert('Произошла ошибка при обрезке изображения'); } finally { applyBtn.disabled = false; applyBtn.innerHTML = originalText; } } /** * Обновляет превью аватара */ updatePreview(canvas) { const previewImg = document.getElementById(this.config.previewImageId); const placeholder = document.getElementById(this.config.placeholderId); const dataUrl = canvas.toDataURL(this.config.outputFormat, this.config.outputQuality); if (previewImg) { // Если превью - это img элемент previewImg.src = dataUrl; previewImg.classList.remove('d-none'); if (placeholder) { placeholder.classList.add('d-none'); } } else if (placeholder && placeholder.tagName !== 'IMG') { // Если превью - это placeholder, заменяем его на img const img = document.createElement('img'); img.id = this.config.previewImageId; img.src = dataUrl; img.alt = 'Фото'; img.className = 'rounded'; img.style.cssText = 'width: 120px; height: 120px; object-fit: cover; border: 2px solid #dee2e6;'; placeholder.replaceWith(img); } } /** * Обновляет input файла с обрезанным изображением */ updateFileInput(croppedFile) { const fileInput = document.querySelector(this.config.fileInputSelector); if (!fileInput) return; // Создаем новый DataTransfer объект const dataTransfer = new DataTransfer(); dataTransfer.items.add(croppedFile); // Обновляем files input fileInput.files = dataTransfer.files; console.log('[AvatarCropper] File input updated with cropped image:', { name: croppedFile.name, size: croppedFile.size, type: croppedFile.type }); } /** * Уничтожает экземпляр Cropper */ destroyCropper() { if (this.cropper) { this.cropper.destroy(); this.cropper = null; } // Очищаем src изображения const cropperImage = document.getElementById('cropperImage'); if (cropperImage) { cropperImage.src = ''; } } } /** * Инициализирует Avatar Cropper * @param {Object} config - Конфигурация * @returns {AvatarCropper} Экземпляр AvatarCropper */ export function initAvatarCropper(config = {}) { return new AvatarCropper(config); }