feat: add image zoom, pan, and reset controls to document viewer
This commit is contained in:
parent
b78c9da50e
commit
c25c4a4415
13 changed files with 316 additions and 4 deletions
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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 · en cours"
|
||||||
|
msgstr "to process · in progress"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/stock_summary.html
|
||||||
|
msgid "Aucun bon à traiter ou en cours"
|
||||||
|
msgstr "No orders to process or in progress"
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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 · en cours"
|
||||||
|
msgstr "à traiter · en cours"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/stock_summary.html
|
||||||
|
msgid "Aucun bon à traiter ou en cours"
|
||||||
|
msgstr "Aucun bon à traiter ou en cours"
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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 · en cours"
|
||||||
|
msgstr "te verwerken · in behandeling"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/stock_summary.html
|
||||||
|
msgid "Aucun bon à traiter ou en cours"
|
||||||
|
msgstr "Geen bonnen te verwerken of in behandeling"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue