Corp/backend/static/js/components/avatarCropperHandler.js
Игорь Надеин 28feac732e feat(avatar): change crop ratio to 3:4 portrait and improve overlay sync
Changed avatar cropper aspect ratio from 1:1 to 3:4 (portrait) for better FaceID recognition.
Improved human silhouette overlay synchronization with crop box during all interactions.

Key changes:

Aspect ratio:
- Changed from 1:1 (square) to 3:4 (portrait)
- Output size: 600x800px (instead of 800x800px)
- Better suited for full body positioning (head, shoulders, torso)

SVG overlay improvements:
- Updated viewBox from 400x400 to 300x400 (3:4 proportions)
- Redesigned human silhouette for portrait format
- Added torso outline up to waist level
- Additional guide line for shoulders
- Better proportions for face/head/shoulders alignment

Synchronization enhancements:
- Added cropstart, cropmove, cropend event handlers
- Overlay now follows crop box during all interactions:
  ✓ Moving the crop box
  ✓ Resizing the crop box
  ✓ Zooming the image
  ✓ Initial positioning
- Real-time position updates using getCropBoxData()

UI/UX updates:
- Updated instructions: 'Совместите тело с зелёным контуром'
- Face should occupy upper third of frame
- Portrait format 3:4 mentioned in requirements
- Changed label from 'Плечи' to 'Пояс' (waist)
- Templates updated with new size requirements (600x800px)

Technical details:
- minCropBoxHeight: 266px (200 * 4/3 for aspect ratio)
- preserveAspectRatio: none (for proper stretching)
- Event handlers ensure overlay follows crop box smoothly
- No performance impact from additional event listeners

Benefits:
✓ Better for FaceID (full body context)
✓ Standard portrait photo format
✓ More professional appearance
✓ Consistent with ID photo standards
✓ Improved overlay visibility and accuracy
2025-12-18 10:15:00 +03:00

546 lines
19 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: 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: `
<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><strong>Совместите тело с зелёным контуром (голова, плечи, торс)</strong></li>
<li>Лицо должно занимать верхнюю треть кадра</li>
<li>Расположите лицо в центре по вертикальной линии</li>
<li>Избегайте теней и бликов</li>
<li>Смотрите прямо в камеру</li>
<li>Нейтральное выражение лица</li>
<li>Формат: портретное фото 3:4</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 position-relative" style="max-height: 500px; overflow: hidden;">
<img id="cropperImage" style="max-width: 100%; display: block;">
<!-- SVG оверлей с контуром человека (3:4 пропорции) -->
<svg id="humanOverlay" class="position-absolute"
style="pointer-events: none; z-index: 1000; display: none;"
viewBox="0 0 300 400" preserveAspectRatio="none">
<!-- Контур головы и плеч -->
<g fill="none" stroke="#00ff00" stroke-width="3" stroke-opacity="0.8" stroke-dasharray="8,4">
<!-- Голова (эллипс) - увеличена для портретного формата -->
<ellipse cx="150" cy="120" rx="75" ry="95"/>
<!-- Шея -->
<path d="M 115 200 L 115 230 M 185 200 L 185 230"/>
<!-- Плечи -->
<path d="M 40 320 Q 115 230, 150 230 Q 185 230, 260 320"/>
<!-- Туловище (контур до пояса) -->
<path d="M 40 320 L 60 400 M 260 320 L 240 400"/>
</g>
<!-- Направляющие линии -->
<g stroke="#00ff00" stroke-width="1" stroke-opacity="0.4" stroke-dasharray="4,4">
<!-- Вертикальная центральная линия -->
<line x1="150" y1="0" x2="150" y2="400"/>
<!-- Горизонтальная линия глаз -->
<line x1="0" y1="120" x2="300" y2="120"/>
<!-- Горизонтальная линия плеч -->
<line x1="0" y1="230" x2="300" y2="230"/>
</g>
<!-- Подсказки -->
<text x="150" y="25" text-anchor="middle" fill="#00ff00" font-size="16" font-weight="bold">
↓ Верх головы
</text>
<text x="150" y="385" text-anchor="middle" fill="#00ff00" font-size="16" font-weight="bold">
↑ Пояс
</text>
</svg>
</div>
<!-- Индикатор размера и контроль оверлея -->
<div class="mt-3 d-flex justify-content-between align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="toggleHumanOverlay" checked>
<label class="form-check-label" for="toggleHumanOverlay">
<small><i class="bi-person-bounding-box me-1"></i>Показать контур человека</small>
</label>
</div>
<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));
}
// Обработчик переключения контура человека
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 = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
// Получаем обрезанное изображение в пропорциях 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);
}