Corp/backend/static/js/components/avatarCropperHandler.js
Игорь Надеин d6e974746e feat(avatar): implement avatar cropper with FaceID requirements
Implemented avatar cropping component for FaceID system integration.

Features:
- Image cropper using Cropper.js library
- FaceID requirements validation (70-80% face coverage, 600x600px min)
- Square crop area (1:1 aspect ratio)
- Output: 800x800px JPEG at 92% quality
- Real-time size indicator
- Integrated in 2 places: registration + profile editor

Components:
- avatarCropperHandler.js: Main cropper module with AvatarCropper class
- Modal UI with Bootstrap 5
- FaceID instructions and guidelines
- File validation (size, format, dimensions)

Integration:
- auth/register.html: Registration form with cropper
- employees/employee_form_personal.html: Profile editor with cropper
- employees/employee_form_scripts.html: Scripts updated

Technical details:
- CDN: Cropper.js v1.6.1
- Min input size: 600x600px
- Max file size: 10 MB
- Formats: JPG, PNG, GIF
- Output format: JPEG (optimized for face recognition)

Validation:
✓ File format validation
✓ File size validation (10 MB max)
✓ Image dimensions validation (600x600px min)
✓ Real-time crop size indicator
✓ Error handling with user-friendly messages

UI/UX:
- Modal window with instructions
- Live preview of cropped image
- Size indicator (green/red based on requirements)
- Clear FaceID guidelines for users
- Responsive layout
2025-12-18 09:56:50 +03:00

437 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: 1, // Квадратное фото
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: 200,
},
// Требования для FaceID
minOutputSize: 600, // Минимальный размер выходного изображения
outputSize: 800, // Рекомендуемый размер выходного изображения
outputQuality: 0.92, // Качество JPEG (0.0 - 1.0)
outputFormat: 'image/jpeg', // Формат выходного файла
// Тексты для UI
texts: {
modalTitle: 'Обрезка фото для FaceID',
instructions: `
<div class="alert alert-info mb-3">
<h6 class="mb-2"><i class="bi-info-circle me-2"></i>Требования для системы распознавания лиц:</h6>
<ul class="mb-0 small">
<li>Лицо должно занимать 70-80% кадра</li>
<li>Расположите лицо в центре рамки</li>
<li>Избегайте теней и бликов</li>
<li>Смотрите прямо в камеру</li>
<li>Нейтральное выражение лица</li>
</ul>
</div>
`,
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 = `
<div class="modal fade" id="${this.config.modalId}" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${this.config.texts.modalTitle}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
${this.config.texts.instructions}
<!-- Контейнер для Cropper.js -->
<div class="cropper-container-wrapper" style="max-height: 500px; overflow: hidden;">
<img id="cropperImage" style="max-width: 100%; display: block;">
</div>
<!-- Индикатор размера -->
<div class="mt-3 text-center">
<small class="text-muted" id="cropperSizeIndicator">
Размер обрезанного фото: <span class="fw-bold">-</span>
</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
${this.config.texts.cancelButton}
</button>
<button type="button" class="btn btn-primary" id="cropperApplyBtn">
<i class="bi-check-circle me-2"></i>${this.config.texts.cropButton}
</button>
</div>
</div>
</div>
</div>
`;
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));
}
}
/**
* Обработка выбора файла
*/
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();
},
crop: (event) => {
this.updateSizeIndicator(event.detail);
}
});
}
/**
* Обновляет индикатор размера обрезанного изображения
*/
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 = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
// Получаем обрезанное изображение
const canvas = this.cropper.getCroppedCanvas({
width: this.config.outputSize,
height: this.config.outputSize,
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);
}