feat: add image zoom, pan, and reset controls to document viewer

This commit is contained in:
kdeterme 2026-09-29 22:17:20 +02:00
parent b78c9da50e
commit c25c4a4415
13 changed files with 316 additions and 4 deletions

View file

@ -541,3 +541,15 @@ msgstr ""
#: common/views.py:644 #: common/views.py:644
msgid "Erreur lors de la lecture du document" msgid "Erreur lors de la lecture du document"
msgstr "Erreur lors de la lecture du document" msgstr "Erreur lors de la lecture du document"
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Zoom out"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Zoom in"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Reset"

View file

@ -527,3 +527,15 @@ msgstr "Accès interdit"
#: common/views.py:644 #: common/views.py:644
msgid "Erreur lors de la lecture du document" msgid "Erreur lors de la lecture du document"
msgstr "Erreur lors de la lecture du document" msgstr "Erreur lors de la lecture du document"
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Dézoomer"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Zoomer"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Réinitialiser"

View file

@ -527,3 +527,15 @@ msgstr ""
#: common/views.py:644 #: common/views.py:644
msgid "Erreur lors de la lecture du document" msgid "Erreur lors de la lecture du document"
msgstr "" msgstr ""
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Uitzoomen"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Inzoomen"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Herstellen"

View file

@ -221,10 +221,29 @@
style="width: 100%; height: 70vh; border: none;" style="width: 100%; height: 70vh; border: none;"
title="{{ doc.title }}"></iframe> title="{{ doc.title }}"></iframe>
{% elif doc.is_image %} {% elif doc.is_image %}
<img src="{{ doc.file_url }}" <div class="doc-viewer-image-viewport position-relative w-100 d-flex justify-content-center align-items-center overflow-hidden"
class="d-block" style="height: 70vh; min-height: 60vh; user-select: none;">
alt="{{ doc.title }}" <img src="{{ doc.file_url }}"
style="max-height: 70vh; max-width: 100%; object-fit: contain;"> class="doc-viewer-zoom-img"
alt="{{ doc.title }}"
draggable="false"
style="max-height: 70vh; max-width: 100%; object-fit: contain; cursor: zoom-in; transition: transform 0.15s ease-out; transform-origin: center center;">
{# Floating zoom controls toolbar #}
<div class="doc-viewer-zoom-toolbar position-absolute bottom-0 start-50 translate-middle-x mb-2 d-flex align-items-center gap-1 px-2 py-1 rounded-pill shadow-sm"
style="background: rgba(33, 37, 41, 0.78); backdrop-filter: blur(4px); z-index: 5;">
<button type="button" class="btn btn-sm btn-link text-white p-1 text-decoration-none doc-zoom-out" title="{% translate 'Dézoomer' %}">
<i class="bi bi-dash-circle fs-6"></i>
</button>
<span class="text-white-50 px-1 doc-zoom-level fw-semibold" style="font-size: 0.75rem; min-width: 44px; text-align: center;">100%</span>
<button type="button" class="btn btn-sm btn-link text-white p-1 text-decoration-none doc-zoom-in" title="{% translate 'Zoomer' %}">
<i class="bi bi-plus-circle fs-6"></i>
</button>
<button type="button" class="btn btn-sm btn-link text-white p-1 text-decoration-none doc-zoom-reset" title="{% translate 'Réinitialiser' %}">
<i class="bi bi-arrow-counterclockwise fs-6"></i>
</button>
</div>
</div>
{% elif doc.text_preview %} {% elif doc.text_preview %}
<div class="w-100 p-3 overflow-auto" style="max-height: 70vh;"> <div class="w-100 p-3 overflow-auto" style="max-height: 70vh;">
<pre class="bg-light border rounded p-3 mb-0" style="font-size:.85rem;white-space:pre-wrap;word-break:break-all;">{{ doc.text_preview }}</pre> <pre class="bg-light border rounded p-3 mb-0" style="font-size:.85rem;white-space:pre-wrap;word-break:break-all;">{{ doc.text_preview }}</pre>
@ -366,6 +385,19 @@
</div>{# /modal #} </div>{# /modal #}
{% endif %} {% endif %}
<style>
.doc-viewer-zoom-toolbar button:hover {
color: #0dcaf0 !important;
transform: scale(1.15);
}
.doc-viewer-zoom-toolbar button {
transition: transform 0.1s ease, color 0.1s ease;
}
.doc-viewer-image-viewport {
background-color: #f8f9fa;
}
</style>
{# ═══════════════════════════ Inline JS ══════════════════════════════ #} {# ═══════════════════════════ Inline JS ══════════════════════════════ #}
<script> <script>
(function () { (function () {
@ -434,6 +466,223 @@
/* ── Initialise download button when modal becomes visible ── */ /* ── Initialise download button when modal becomes visible ── */
modal.addEventListener('shown.bs.modal', updateDownloadBtn); modal.addEventListener('shown.bs.modal', updateDownloadBtn);
/* ── Image Zoom & Pan functionality ── */
function initImageZoom() {
const viewports = carousel.querySelectorAll('.doc-viewer-image-viewport');
viewports.forEach(function (vp) {
const img = vp.querySelector('.doc-viewer-zoom-img');
const zoomInBtn = vp.querySelector('.doc-zoom-in');
const zoomOutBtn = vp.querySelector('.doc-zoom-out');
const zoomResetBtn = vp.querySelector('.doc-zoom-reset');
const zoomLevel = vp.querySelector('.doc-zoom-level');
if (!img) return;
let scale = 1;
let posX = 0;
let posY = 0;
let isDragging = false;
let startPointerX = 0;
let startPointerY = 0;
let initialPinchDist = 0;
let initialScale = 1;
function clampPan(targetX, targetY, curScale) {
if (curScale <= 1) return { x: 0, y: 0 };
const vpWidth = vp.clientWidth;
const vpHeight = vp.clientHeight;
const maxPanX = Math.max(0, (vpWidth * (curScale - 1)) / 2 + 50);
const maxPanY = Math.max(0, (vpHeight * (curScale - 1)) / 2 + 50);
return {
x: Math.min(maxPanX, Math.max(-maxPanX, targetX)),
y: Math.min(maxPanY, Math.max(-maxPanY, targetY))
};
}
function applyTransform(animate) {
img.style.transition = animate ? 'transform 0.15s ease-out' : 'none';
img.style.transform = 'translate(' + posX + 'px, ' + posY + 'px) scale(' + scale + ')';
if (zoomLevel) {
zoomLevel.textContent = Math.round(scale * 100) + '%';
if (scale > 1.05) {
zoomLevel.classList.remove('text-white-50');
zoomLevel.classList.add('text-white');
} else {
zoomLevel.classList.remove('text-white');
zoomLevel.classList.add('text-white-50');
}
}
if (scale > 1.05) {
img.style.cursor = isDragging ? 'grabbing' : 'grab';
} else {
img.style.cursor = 'zoom-in';
}
}
function setZoom(newScale, focalX, focalY, animate) {
const prevScale = scale;
scale = Math.min(5, Math.max(1, newScale));
if (scale <= 1) {
scale = 1;
posX = 0;
posY = 0;
} else if (focalX !== undefined && focalY !== undefined && prevScale !== scale) {
const vpRect = vp.getBoundingClientRect();
const cx = focalX - (vpRect.left + vpRect.width / 2);
const cy = focalY - (vpRect.top + vpRect.height / 2);
const ratio = scale / prevScale;
posX = posX - cx * (ratio - 1);
posY = posY - cy * (ratio - 1);
const clamped = clampPan(posX, posY, scale);
posX = clamped.x;
posY = clamped.y;
} else {
const clamped = clampPan(posX, posY, scale);
posX = clamped.x;
posY = clamped.y;
}
applyTransform(animate !== false);
}
function resetZoom() {
scale = 1;
posX = 0;
posY = 0;
applyTransform(true);
}
vp._resetZoom = resetZoom;
if (zoomInBtn) {
zoomInBtn.addEventListener('click', function (e) {
e.stopPropagation();
setZoom(scale * 1.35, undefined, undefined, true);
});
}
if (zoomOutBtn) {
zoomOutBtn.addEventListener('click', function (e) {
e.stopPropagation();
setZoom(scale / 1.35, undefined, undefined, true);
});
}
if (zoomResetBtn) {
zoomResetBtn.addEventListener('click', function (e) {
e.stopPropagation();
resetZoom();
});
}
vp.addEventListener('wheel', function (e) {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : (1 / 1.15);
setZoom(scale * factor, e.clientX, e.clientY, false);
}, { passive: false });
vp.addEventListener('dblclick', function (e) {
if (e.target.closest('.doc-viewer-zoom-toolbar')) return;
e.preventDefault();
if (scale > 1.2) {
resetZoom();
} else {
setZoom(2.5, e.clientX, e.clientY, true);
}
});
vp.addEventListener('mousedown', function (e) {
if (e.target.closest('.doc-viewer-zoom-toolbar')) return;
if (scale <= 1) return;
isDragging = true;
startPointerX = e.clientX - posX;
startPointerY = e.clientY - posY;
img.style.cursor = 'grabbing';
e.preventDefault();
e.stopPropagation();
});
window.addEventListener('mousemove', function (e) {
if (!isDragging) return;
const clamped = clampPan(e.clientX - startPointerX, e.clientY - startPointerY, scale);
posX = clamped.x;
posY = clamped.y;
applyTransform(false);
});
window.addEventListener('mouseup', function () {
if (!isDragging) return;
isDragging = false;
if (scale > 1) img.style.cursor = 'grab';
});
function getTouchDistance(t1, t2) {
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
vp.addEventListener('touchstart', function (e) {
if (e.target.closest('.doc-viewer-zoom-toolbar')) return;
if (e.touches.length === 2) {
initialPinchDist = getTouchDistance(e.touches[0], e.touches[1]);
initialScale = scale;
e.preventDefault();
e.stopPropagation();
} else if (e.touches.length === 1 && scale > 1) {
isDragging = true;
startPointerX = e.touches[0].clientX - posX;
startPointerY = e.touches[0].clientY - posY;
e.stopPropagation();
}
}, { passive: false });
vp.addEventListener('touchmove', function (e) {
if (e.target.closest('.doc-viewer-zoom-toolbar')) return;
if (e.touches.length === 2 && initialPinchDist > 0) {
const currentDist = getTouchDistance(e.touches[0], e.touches[1]);
const factor = currentDist / initialPinchDist;
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
setZoom(initialScale * factor, midX, midY, false);
e.preventDefault();
e.stopPropagation();
} else if (e.touches.length === 1 && isDragging && scale > 1) {
const clamped = clampPan(e.touches[0].clientX - startPointerX, e.touches[0].clientY - startPointerY, scale);
posX = clamped.x;
posY = clamped.y;
applyTransform(false);
e.preventDefault();
e.stopPropagation();
}
}, { passive: false });
vp.addEventListener('touchend', function (e) {
if (e.touches.length < 2) {
initialPinchDist = 0;
}
if (e.touches.length === 0) {
isDragging = false;
}
});
});
}
function resetAllZooms() {
carousel.querySelectorAll('.doc-viewer-image-viewport').forEach(function (vp) {
if (typeof vp._resetZoom === 'function') {
vp._resetZoom();
}
});
}
carousel.addEventListener('slide.bs.carousel', resetAllZooms);
modal.addEventListener('hidden.bs.modal', resetAllZooms);
initImageZoom();
})(); })();
</script> </script>

View file

@ -1137,3 +1137,12 @@ msgstr "No interventions to control"
#: home/templates/home/widgets/controller_shortcuts.html:72 #: home/templates/home/widgets/controller_shortcuts.html:72
msgid "Voir toutes les interventions à contrôler" msgid "Voir toutes les interventions à contrôler"
msgstr "View all interventions to control" msgstr "View all interventions to control"
#: home/templates/home/widgets/stock_summary.html
msgid "à traiter &middot; en cours"
msgstr "to process &middot; in progress"
#: home/templates/home/widgets/stock_summary.html
msgid "Aucun bon à traiter ou en cours"
msgstr "No orders to process or in progress"

View file

@ -1146,3 +1146,12 @@ msgstr "Aucune intervention à contrôler"
#: home/templates/home/widgets/controller_shortcuts.html:72 #: home/templates/home/widgets/controller_shortcuts.html:72
msgid "Voir toutes les interventions à contrôler" msgid "Voir toutes les interventions à contrôler"
msgstr "Voir toutes les interventions à contrôler" msgstr "Voir toutes les interventions à contrôler"
#: home/templates/home/widgets/stock_summary.html
msgid "à traiter &middot; en cours"
msgstr "à traiter &middot; en cours"
#: home/templates/home/widgets/stock_summary.html
msgid "Aucun bon à traiter ou en cours"
msgstr "Aucun bon à traiter ou en cours"

View file

@ -1143,3 +1143,12 @@ msgstr "Geen te controleren interventies"
#: home/templates/home/widgets/controller_shortcuts.html:72 #: home/templates/home/widgets/controller_shortcuts.html:72
msgid "Voir toutes les interventions à contrôler" msgid "Voir toutes les interventions à contrôler"
msgstr "Alle te controleren interventies bekijken" msgstr "Alle te controleren interventies bekijken"
#: home/templates/home/widgets/stock_summary.html
msgid "à traiter &middot; en cours"
msgstr "te verwerken &middot; in behandeling"
#: home/templates/home/widgets/stock_summary.html
msgid "Aucun bon à traiter ou en cours"
msgstr "Geen bonnen te verwerken of in behandeling"