Rewrite profile photo crop/zoom logic

Display and crop were using inconsistent coordinate systems.
Display now uses: translate(translateX+offsetX, translateY+offsetY) scale(zoomLevel)
Crop uses: srcX = -(offsetX + translateX) / scale

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 15:13:49 +00:00
parent 64ab1d0412
commit c823345676
+128 -102
View File
@@ -2,37 +2,68 @@
{% block title %}{{ 'profile'|t }} - NetEvents{% endblock %} {% block title %}{{ 'profile'|t }} - NetEvents{% endblock %}
{% block extra_styles %}
<style>
.profile-page {
max-width: 900px;
margin: 0 auto;
padding: 50px 40px;
}
.profile-container {
display: flex;
gap: 30px;
align-items: flex-start;
}
.photo-box {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 20px;
text-align: center;
}
.photo-box h3 {
margin-bottom: 15px;
color: #2c3e50;
}
.profile-photo-section {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="profile-page"> <div class="profile-page">
<h1>{{ 'my_profile'|t }}</h1> <h1>{{ 'my_profile'|t }}</h1>
<div class="profile-container"> <div class="profile-container">
<div class="profile-photo-section"> <div class="photo-box">
<div class="current-photo" id="photo-container"> <h3>{{ 'profile_photo'|t }}</h3>
{% if attendee.profile_picture %} <div class="profile-photo-section">
<img src="{{ url_for('static', filename='uploads/' + attendee.profile_picture) }}" alt="{{ 'profile_photo'|t }}" class="profile-img" id="profile-preview"> <div class="current-photo" id="photo-container">
{% else %} {% if attendee.profile_picture %}
<img src="" alt="{{ 'profile_preview'|t }}" class="profile-img" id="profile-preview" style="display: none;"> <img src="{{ url_for('static', filename='uploads/' + attendee.profile_picture) }}" alt="{{ 'profile_photo'|t }}" class="profile-img" id="profile-preview">
<div class="no-photo" id="no-photo">{{ 'no_photo'|t }}</div> {% else %}
{% endif %} <img src="" alt="{{ 'profile_preview'|t }}" class="profile-img" id="profile-preview" style="display: none;">
</div> <div class="no-photo" id="no-photo">{{ 'no_photo'|t }}</div>
{% endif %}
</div>
<canvas id="crop-canvas" style="display: none;"></canvas> <canvas id="crop-canvas" style="display: none;"></canvas>
<div class="zoom-controls"> <div class="zoom-controls">
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)">{{ 'zoom_out'|t }}</button> <button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)">-</button>
<span id="zoom-level">100%</span> <span id="zoom-level">100%</span>
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(0.25)">{{ 'zoom_in'|t }}</button> <button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(0.25)">+</button>
<button type="button" class="btn btn-sm btn-outline" onclick="resetImage()">{{ 'reset'|t }}</button> <button type="button" class="btn btn-sm btn-outline" onclick="handleFormSubmit()">{{ 'save'|t }}</button>
</div> </div>
<form method="POST" action="{{ url_for('upload_photo') }}" enctype="multipart/form-data" class="photo-upload-form" id="photo-form">
<div class="form-group"> <div class="form-group">
<label for="photo">{{ 'upload_photo'|t }}</label> <label for="photo">{{ 'upload_photo'|t }}</label>
<input type="file" id="photo" name="photo" accept="image/*"> <input type="file" id="photo" name="photo" accept="image/*">
</div> </div>
<button type="submit" class="btn btn-sm btn-primary" id="upload-btn">{{ 'upload'|t }}</button> </div>
</form>
</div> </div>
<form method="POST" action="{{ url_for('attendee_profile') }}" class="profile-form"> <form method="POST" action="{{ url_for('attendee_profile') }}" class="profile-form">
@@ -56,6 +87,16 @@
<input type="text" id="role" name="role" value="{{ attendee.role or '' }}"> <input type="text" id="role" name="role" value="{{ attendee.role or '' }}">
</div> </div>
<div class="form-group">
<label for="phone">{{ 'phone'|t }}</label>
<input type="tel" id="phone" name="phone" value="{{ attendee.phone or '' }}">
</div>
<div class="form-group">
<label for="linkedin">{{ 'linkedin'|t }}</label>
<input type="url" id="linkedin" name="linkedin" value="{{ attendee.linkedin or '' }}">
</div>
<div class="form-group"> <div class="form-group">
<label for="introduction">{{ 'short_introduction'|t }}</label> <label for="introduction">{{ 'short_introduction'|t }}</label>
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea> <textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
@@ -74,13 +115,13 @@
height: 150px; height: 150px;
margin-bottom: 10px; margin-bottom: 10px;
overflow: hidden; overflow: hidden;
border-radius: 50%; border-radius: 0;
position: relative; position: relative;
background: #e0e0e0; background: #e0e0e0;
} }
.profile-img { .profile-img {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 0;
cursor: move; cursor: move;
} }
.no-photo, #no-photo { .no-photo, #no-photo {
@@ -103,9 +144,9 @@
</style> </style>
<script> <script>
var zoomLevel = 1; var zoomLevel = 1.0;
var panX = 0; var translateX = 0;
var panY = 0; var translateY = 0;
var isDragging = false; var isDragging = false;
var dragStartX = 0; var dragStartX = 0;
var dragStartY = 0; var dragStartY = 0;
@@ -129,8 +170,6 @@ function init() {
document.addEventListener('mouseup', endDrag); document.addEventListener('mouseup', endDrag);
container.addEventListener('wheel', handleWheel); container.addEventListener('wheel', handleWheel);
} }
document.getElementById('photo-form').addEventListener('submit', handleFormSubmit);
} }
function handleFileSelect(e) { function handleFileSelect(e) {
@@ -142,7 +181,9 @@ function handleFileSelect(e) {
var img = new Image(); var img = new Image();
img.onload = function() { img.onload = function() {
originalImage = img; originalImage = img;
resetImage(); zoomLevel = 1.0;
translateX = 0;
translateY = 0;
preview.src = event.target.result; preview.src = event.target.result;
preview.style.display = 'block'; preview.style.display = 'block';
var noPhoto = document.getElementById('no-photo'); var noPhoto = document.getElementById('no-photo');
@@ -155,24 +196,15 @@ function handleFileSelect(e) {
} }
function adjustZoom(delta) { function adjustZoom(delta) {
var newZoom = zoomLevel + delta; var oldZoom = zoomLevel;
if (newZoom < 1) newZoom = 1; zoomLevel = Math.max(1.0, Math.min(3.0, zoomLevel + delta));
if (newZoom > 3) newZoom = 3;
zoomLevel = newZoom;
document.getElementById('zoom-level').textContent = Math.round(zoomLevel * 100) + '%'; document.getElementById('zoom-level').textContent = Math.round(zoomLevel * 100) + '%';
if (zoomLevel === 1) { // Adjust translation to zoom toward center
panX = 0; var factor = zoomLevel / oldZoom;
panY = 0; translateX *= factor;
} translateY *= factor;
updateDisplay();
}
function resetImage() {
zoomLevel = 1;
panX = 0;
panY = 0;
document.getElementById('zoom-level').textContent = '100%';
updateDisplay(); updateDisplay();
} }
@@ -182,66 +214,65 @@ function updateDisplay() {
var img = originalImage; var img = originalImage;
var imgW = img.naturalWidth; var imgW = img.naturalWidth;
var imgH = img.naturalHeight; var imgH = img.naturalHeight;
var imgAspect = imgW / imgH;
// Calculate display size (object-fit: cover) var scale, dispW, dispH, offsetX, offsetY;
var dispW, dispH; if (imgW >= imgH) {
if (imgAspect > 1) { scale = containerSize / imgH;
dispH = containerSize; dispH = containerSize;
dispW = dispH * imgAspect; dispW = imgW * scale;
offsetX = (containerSize - dispW) / 2;
offsetY = 0;
} else { } else {
scale = containerSize / imgW;
dispW = containerSize; dispW = containerSize;
dispH = dispW / imgAspect; dispH = imgH * scale;
offsetX = 0;
offsetY = (containerSize - dispH) / 2;
} }
// Center offset for object-fit: cover preview.style.transform = 'translate(' + (translateX + offsetX) + 'px, ' + (translateY + offsetY) + 'px) scale(' + zoomLevel + ')';
var offsetX = (containerSize - dispW) / 2;
var offsetY = (containerSize - dispH) / 2;
// Apply pan (drag right = see right side of image)
var totalX = offsetX + panX;
var totalY = offsetY + panY;
preview.style.width = dispW + 'px';
preview.style.height = dispH + 'px';
preview.style.left = totalX + 'px';
preview.style.top = totalY + 'px';
preview.style.transform = 'scale(' + zoomLevel + ')';
preview.style.transformOrigin = 'center center';
} }
function startDrag(e) { function startDrag(e) {
if (!originalImage) return; if (!originalImage || zoomLevel <= 1.0) return;
isDragging = true; isDragging = true;
dragStartX = e.clientX - panX; dragStartX = e.clientX - translateX;
dragStartY = e.clientY - panY; dragStartY = e.clientY - translateY;
preview.style.cursor = 'grabbing'; preview.style.cursor = 'grabbing';
e.preventDefault(); e.preventDefault();
} }
function doDrag(e) { function doDrag(e) {
if (!isDragging) return; if (!isDragging) return;
panX = e.clientX - dragStartX; translateX = e.clientX - dragStartX;
panY = e.clientY - dragStartY; translateY = e.clientY - dragStartY;
updateDisplay(); updateDisplay();
} }
function endDrag() { function endDrag() {
isDragging = false; isDragging = false;
if (preview) preview.style.cursor = 'move'; if (preview) preview.style.cursor = zoomLevel > 1.0 ? 'move' : 'default';
} }
function handleWheel(e) { function handleWheel(e) {
e.preventDefault(); e.preventDefault();
if (e.deltaY < 0) { var rect = container.getBoundingClientRect();
adjustZoom(0.1); var mouseX = e.clientX - rect.left - containerSize / 2;
} else { var mouseY = e.clientY - rect.top - containerSize / 2;
adjustZoom(-0.1);
} var oldZoom = zoomLevel;
zoomLevel = Math.max(1.0, Math.min(3.0, zoomLevel - e.deltaY * 0.01));
// Zoom toward mouse position
translateX = mouseX - (mouseX - translateX) * (zoomLevel / oldZoom);
translateY = mouseY - (mouseY - translateY) * (zoomLevel / oldZoom);
document.getElementById('zoom-level').textContent = Math.round(zoomLevel * 100) + '%';
updateDisplay();
} }
function handleFormSubmit(e) { function handleFormSubmit(e) {
e.preventDefault(); if (e) e.preventDefault();
if (!originalImage) { if (!originalImage) {
alert('Please select an image first'); alert('Please select an image first');
@@ -256,43 +287,38 @@ function handleFormSubmit(e) {
var img = originalImage; var img = originalImage;
var imgW = img.naturalWidth; var imgW = img.naturalWidth;
var imgH = img.naturalHeight; var imgH = img.naturalHeight;
var imgAspect = imgW / imgH;
// Calculate display size (object-fit: cover) // Calculate how the image fits in the container (object-fit: cover)
var dispW, dispH; var scale, dispW, dispH, offsetX, offsetY;
if (imgAspect > 1) { if (imgW >= imgH) {
scale = containerSize / imgH;
dispH = containerSize; dispH = containerSize;
dispW = dispH * imgAspect; dispW = imgW * scale;
offsetX = (containerSize - dispW) / 2;
offsetY = 0;
} else { } else {
scale = containerSize / imgW;
dispW = containerSize; dispW = containerSize;
dispH = dispW / imgAspect; dispH = imgH * scale;
offsetX = 0;
offsetY = (containerSize - dispH) / 2;
} }
// Centering offsets // Display: translate(translateX + offsetX, translateY + offsetY) scale(zoomLevel)
var offsetX = (containerSize - dispW) / 2; // Container pixel (cx, cy) maps to source: sx = (cx - offsetX - translateX) / scale
var offsetY = (containerSize - dispH) / 2; // So container pixel (0,0) maps to source: srcX = -(offsetX + translateX) / scale
var srcX = -(offsetX + translateX) / scale;
// Scale factors from display to image coordinates var srcY = -(offsetY + translateY) / scale;
var scaleX = imgW / dispW; var srcW = containerSize / scale / zoomLevel;
var scaleY = imgH / dispH; var srcH = containerSize / scale / zoomLevel;
// Source crop region
var srcX = (offsetX + panX + containerSize / 2) / (scaleX * zoomLevel) - containerSize / (2 * scaleX);
var srcY = (offsetY + panY + containerSize / 2) / (scaleY * zoomLevel) - containerSize / (2 * scaleY);
var srcW = containerSize / (scaleX * zoomLevel);
var srcH = containerSize / (scaleY * zoomLevel);
// Clamp to image bounds // Clamp to image bounds
srcX = Math.max(0, srcX); srcX = Math.max(0, Math.min(srcX, imgW - srcW));
srcY = Math.max(0, srcY); srcY = Math.max(0, Math.min(srcY, imgH - srcH));
srcW = Math.min(imgW - srcX, srcW); srcW = Math.min(srcW, imgW - srcX);
srcH = Math.min(imgH - srcY, srcH); srcH = Math.min(srcH, imgH - srcY);
// Draw with circular clip ctx.clearRect(0, 0, 300, 300);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(150, 150, 150, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, 300, 300); ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, 300, 300);
canvas.toBlob(function(blob) { canvas.toBlob(function(blob) {