Add zoom and pan to annotation canvas and event detail lightbox

Annotation canvas:
- Mouse wheel: zoom centered on cursor (1×→20×)
- Right-click drag or Space+drag: pan
- Two-finger pinch-to-zoom on touch devices
- Bbox coordinates computed in logical image space (zoom-invariant)
- "Plein écran" button opens current frame in new tab at full res
- Zoom % indicator + reset button

Event detail lightbox:
- Mouse wheel: zoom in/out (0.5×→20×)
- Drag to pan when zoomed in
- "Ouvrir en plein écran" link opens raw frame at native 4K in new tab
- Zoom resets on image change or lightbox close

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-03 10:42:37 +02:00
co-authored by Claude Sonnet 4.6
parent fd52942bd1
commit 01c5363649
2 changed files with 258 additions and 57 deletions
+205 -54
View File
@@ -67,12 +67,17 @@
<!-- Canvas --> <!-- Canvas -->
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div id="canvas-wrap"> <div id="canvas-wrap" style="position:relative;">
<canvas id="ann-canvas"></canvas> <canvas id="ann-canvas"></canvas>
</div> </div>
<div class="px-3 py-2 flex items-center justify-between flex-wrap gap-2" style="background:#111827;"> <div class="px-3 py-2 flex items-center justify-between flex-wrap gap-2" style="background:#111827;">
<span id="bbox-status" class="text-sm text-slate-500">Clique et glisse sur l'image pour dessiner le rectangle autour de la plaque</span> <span id="bbox-status" class="text-sm text-slate-500">Clique et glisse pour dessiner le rectangle · Molette pour zoomer · Clic droit ou Espace+glisser pour déplacer</span>
<button onclick="clearRect()" class="btn-ghost text-xs py-1 px-3">✕ Effacer le rectangle</button> <div class="flex items-center gap-2">
<span id="zoom-label" class="text-xs text-slate-500 font-mono">100%</span>
<button onclick="resetZoom()" class="btn-ghost text-xs py-1 px-2">⊡ 1:1</button>
<button id="btn-fullres" onclick="openFullRes()" class="btn-ghost text-xs py-1 px-2">🔍 Plein écran</button>
<button onclick="clearRect()" class="btn-ghost text-xs py-1 px-3">✕ Effacer</button>
</div>
</div> </div>
</div> </div>
@@ -173,73 +178,151 @@
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const img = new Image(); const img = new Image();
let currentFrameSrc = ''; let currentFrameSrc = '';
let startX = 0, startY = 0, endX = 0, endY = 0;
let isDrawing = false;
let rect = null; // finalized {x, y, w, h} in canvas pixels
// ── Image loading ────────────────────────────────────────────────────── // ── Viewport (zoom + pan) ──────────────────────────────────────────────
function loadImage(src) { // All coordinates are in "canvas logical space" (0..canvas.width, 0..canvas.height).
img.src = src; // The viewport transform maps logical coords → screen pixels on the canvas element.
let vp = { scale: 1, tx: 0, ty: 0 };
function clampVP() {
const minTx = canvas.width * (1 - vp.scale);
const minTy = canvas.height * (1 - vp.scale);
vp.tx = Math.min(0, Math.max(minTx, vp.tx));
vp.ty = Math.min(0, Math.max(minTy, vp.ty));
} }
img.onload = function() { // Convert a client-space (screen) position to logical canvas coords
const wrap = document.getElementById('canvas-wrap'); function clientToLogical(cx, cy) {
canvas.width = wrap.clientWidth; const r = canvas.getBoundingClientRect();
canvas.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth); const px = (cx - r.left) * (canvas.width / r.width);
redraw(); const py = (cy - r.top) * (canvas.height / r.height);
}; return [(px - vp.tx) / vp.scale, (py - vp.ty) / vp.scale];
}
function resetZoom() {
vp = { scale: 1, tx: 0, ty: 0 };
document.getElementById('zoom-label').textContent = '100%';
redraw();
}
// ── Rect state (in logical canvas coords) ─────────────────────────────
let rect = null; // finalized: {x1,y1,x2,y2}
let drawStart = null;
let drawEnd = null;
let isDrawing = false;
// ── Pan state ─────────────────────────────────────────────────────────
let isPanning = false;
let panLast = null;
let spaceDown = false;
// ── Render ────────────────────────────────────────────────────────────
function redraw() { function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); ctx.save();
const r = isDrawing ctx.translate(vp.tx, vp.ty);
? { x: Math.min(startX, endX), y: Math.min(startY, endY), ctx.scale(vp.scale, vp.scale);
w: Math.abs(endX - startX), h: Math.abs(endY - startY) } if (img.src) ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
: rect;
// Current bbox in logical coords
let r = null;
if (isDrawing && drawStart && drawEnd) {
r = { x: Math.min(drawStart[0], drawEnd[0]), y: Math.min(drawStart[1], drawEnd[1]),
w: Math.abs(drawEnd[0]-drawStart[0]), h: Math.abs(drawEnd[1]-drawStart[1]) };
} else if (rect) {
r = { x: rect.x1, y: rect.y1, w: rect.x2-rect.x1, h: rect.y2-rect.y1 };
}
if (r && r.w > 4 && r.h > 4) { if (r && r.w > 4 && r.h > 4) {
const lw = 3 / vp.scale;
ctx.strokeStyle = '#3b82f6'; ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 3; ctx.lineWidth = lw;
ctx.strokeRect(r.x, r.y, r.w, r.h); ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.fillStyle = 'rgba(59,130,246,0.12)'; ctx.fillStyle = 'rgba(59,130,246,0.12)';
ctx.fillRect(r.x, r.y, r.w, r.h); ctx.fillRect(r.x, r.y, r.w, r.h);
// Corner handles const hs = 7 / vp.scale;
const hs = 8;
ctx.fillStyle = '#3b82f6'; ctx.fillStyle = '#3b82f6';
[[r.x,r.y],[r.x+r.w,r.y],[r.x,r.y+r.h],[r.x+r.w,r.y+r.h]].forEach(([hx,hy]) => { [[r.x,r.y],[r.x+r.w,r.y],[r.x,r.y+r.h],[r.x+r.w,r.y+r.h]].forEach(([hx,hy]) => {
ctx.fillRect(hx-hs/2, hy-hs/2, hs, hs); ctx.fillRect(hx-hs/2, hy-hs/2, hs, hs);
}); });
} }
ctx.restore();
} }
// ── Mouse events ─────────────────────────────────────────────────────── // ── Mouse wheel → zoom ─────────────────────────────────────────────────
function evPos(e) { canvas.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.25 : 1/1.25;
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
const scaleX = canvas.width / r.width; const mx = (e.clientX - r.left) * (canvas.width / r.width);
const scaleY = canvas.height / r.height; const my = (e.clientY - r.top) * (canvas.height / r.height);
const clientX = e.touches ? e.touches[0].clientX : e.clientX; const newScale = Math.max(1, Math.min(20, vp.scale * factor));
const clientY = e.touches ? e.touches[0].clientY : e.clientY; const f = newScale / vp.scale;
return [(clientX - r.left) * scaleX, (clientY - r.top) * scaleY]; vp.tx = mx - (mx - vp.tx) * f;
} vp.ty = my - (my - vp.ty) * f;
vp.scale = newScale;
clampVP();
document.getElementById('zoom-label').textContent = Math.round(vp.scale * 100) + '%';
redraw();
}, { passive: false });
// ── Space key → pan mode ───────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.code === 'Space' && !e.target.matches('input,textarea')) {
spaceDown = true;
canvas.style.cursor = 'grab';
e.preventDefault();
}
});
document.addEventListener('keyup', e => {
if (e.code === 'Space') { spaceDown = false; canvas.style.cursor = 'crosshair'; }
});
// ── Mouse down ────────────────────────────────────────────────────────
canvas.addEventListener('mousedown', e => { canvas.addEventListener('mousedown', e => {
[startX, startY] = evPos(e); if (e.button === 1 || e.button === 2 || spaceDown) {
isPanning = true;
panLast = [e.clientX, e.clientY];
canvas.style.cursor = 'grabbing';
e.preventDefault();
return;
}
if (e.button !== 0) return;
drawStart = clientToLogical(e.clientX, e.clientY);
drawEnd = [...drawStart];
isDrawing = true; isDrawing = true;
rect = null; rect = null;
updateStatus(false); updateStatus(false);
}); });
canvas.addEventListener('mousemove', e => { canvas.addEventListener('mousemove', e => {
if (isPanning) {
const r = canvas.getBoundingClientRect();
vp.tx += (e.clientX - panLast[0]) * (canvas.width / r.width);
vp.ty += (e.clientY - panLast[1]) * (canvas.height / r.height);
panLast = [e.clientX, e.clientY];
clampVP();
redraw();
return;
}
if (!isDrawing) return; if (!isDrawing) return;
[endX, endY] = evPos(e); drawEnd = clientToLogical(e.clientX, e.clientY);
redraw(); redraw();
}); });
canvas.addEventListener('mouseup', e => { canvas.addEventListener('mouseup', e => {
if (isPanning) {
isPanning = false;
canvas.style.cursor = spaceDown ? 'grab' : 'crosshair';
return;
}
if (!isDrawing) return; if (!isDrawing) return;
[endX, endY] = evPos(e);
isDrawing = false; isDrawing = false;
const x = Math.min(startX, endX), y = Math.min(startY, endY); drawEnd = clientToLogical(e.clientX, e.clientY);
const w = Math.abs(endX - startX), h = Math.abs(endY - startY); const w = Math.abs(drawEnd[0]-drawStart[0]), h = Math.abs(drawEnd[1]-drawStart[1]);
if (w > 8 && h > 4) { if (w > 8 && h > 4) {
rect = { x, y, w, h }; rect = { x1: Math.min(drawStart[0],drawEnd[0]), y1: Math.min(drawStart[1],drawEnd[1]),
x2: Math.max(drawStart[0],drawEnd[0]), y2: Math.max(drawStart[1],drawEnd[1]) };
updateBbox(); updateBbox();
updateStatus(true); updateStatus(true);
} else { } else {
@@ -249,48 +332,115 @@
redraw(); redraw();
}); });
// Touch support canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('touchstart', e => { e.preventDefault(); canvas.dispatchEvent(new MouseEvent('mousedown', {clientX: e.touches[0].clientX, clientY: e.touches[0].clientY})); });
canvas.addEventListener('touchmove', e => { e.preventDefault(); canvas.dispatchEvent(new MouseEvent('mousemove', {clientX: e.touches[0].clientX, clientY: e.touches[0].clientY})); }); // ── Touch (two-finger pinch + single-finger draw) ─────────────────────
canvas.addEventListener('touchend', e => { e.preventDefault(); canvas.dispatchEvent(new MouseEvent('mouseup', {clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY})); }); let touchLast = null, lastDist = null;
canvas.addEventListener('touchstart', e => {
e.preventDefault();
if (e.touches.length === 2) {
lastDist = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
touchLast = [(e.touches[0].clientX+e.touches[1].clientX)/2, (e.touches[0].clientY+e.touches[1].clientY)/2];
} else {
drawStart = clientToLogical(e.touches[0].clientX, e.touches[0].clientY);
drawEnd = [...drawStart];
isDrawing = true; rect = null; updateStatus(false);
}
}, {passive:false});
canvas.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 2) {
const d = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
const mx = (e.touches[0].clientX+e.touches[1].clientX)/2;
const my = (e.touches[0].clientY+e.touches[1].clientY)/2;
if (lastDist) {
const factor = d / lastDist;
const r = canvas.getBoundingClientRect();
const px = (mx - r.left) * (canvas.width/r.width), py = (my - r.top) * (canvas.height/r.height);
const newScale = Math.max(1, Math.min(20, vp.scale * factor));
const f = newScale / vp.scale;
vp.tx = px - (px - vp.tx)*f; vp.ty = py - (py - vp.ty)*f; vp.scale = newScale;
clampVP();
document.getElementById('zoom-label').textContent = Math.round(vp.scale*100)+'%';
}
if (touchLast) { const r=canvas.getBoundingClientRect(); vp.tx+=(mx-touchLast[0])*(canvas.width/r.width); vp.ty+=(my-touchLast[1])*(canvas.height/r.height); clampVP(); }
lastDist = d; touchLast = [mx, my];
redraw();
} else if (isDrawing) {
drawEnd = clientToLogical(e.touches[0].clientX, e.touches[0].clientY);
redraw();
}
}, {passive:false});
canvas.addEventListener('touchend', e => {
e.preventDefault();
lastDist = null; touchLast = null;
if (isDrawing) {
isDrawing = false;
const w = Math.abs(drawEnd[0]-drawStart[0]), h = Math.abs(drawEnd[1]-drawStart[1]);
if (w > 8 && h > 4) {
rect = { x1:Math.min(drawStart[0],drawEnd[0]), y1:Math.min(drawStart[1],drawEnd[1]),
x2:Math.max(drawStart[0],drawEnd[0]), y2:Math.max(drawStart[1],drawEnd[1]) };
updateBbox(); updateStatus(true);
} else { rect = null; updateStatus(false); }
redraw();
}
}, {passive:false});
// ── Helpers ──────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────
function updateBbox() { function updateBbox() {
if (!rect) { document.getElementById('input-bbox').value = ''; return; } if (!rect) { document.getElementById('input-bbox').value = ''; return; }
const cx = (rect.x + rect.w / 2) / canvas.width; const cx = (rect.x1+rect.x2)/2 / canvas.width;
const cy = (rect.y + rect.h / 2) / canvas.height; const cy = (rect.y1+rect.y2)/2 / canvas.height;
const bw = rect.w / canvas.width; const bw = (rect.x2-rect.x1) / canvas.width;
const bh = rect.h / canvas.height; const bh = (rect.y2-rect.y1) / canvas.height;
document.getElementById('input-bbox').value = JSON.stringify( document.getElementById('input-bbox').value = JSON.stringify(
{ cx: +cx.toFixed(6), cy: +cy.toFixed(6), w: +bw.toFixed(6), h: +bh.toFixed(6) } { cx:+cx.toFixed(6), cy:+cy.toFixed(6), w:+bw.toFixed(6), h:+bh.toFixed(6) }
); );
} }
function updateStatus(ok) { function updateStatus(ok) {
const px = ok ? Math.round(rect.x2-rect.x1) : 0;
const py = ok ? Math.round(rect.y2-rect.y1) : 0;
document.getElementById('bbox-status').textContent = ok document.getElementById('bbox-status').textContent = ok
? `Rectangle défini — ${Math.round(rect.w)}×${Math.round(rect.h)}px` ? `Rectangle défini — ${px}×${py}px logiques`
: 'Clique et glisse sur l\'image pour dessiner le rectangle autour de la plaque'; : 'Clique et glisse pour dessiner le rectangle · Molette pour zoomer · Clic droit ou Espace+glisser pour déplacer';
document.getElementById('save-warning').classList.toggle('hidden', ok); document.getElementById('save-warning').classList.toggle('hidden', ok);
document.getElementById('save-ok').classList.toggle('hidden', !ok); document.getElementById('save-ok').classList.toggle('hidden', !ok);
} }
function clearRect() { function clearRect() {
rect = null; rect = null; isDrawing = false; drawStart = null; drawEnd = null;
isDrawing = false;
document.getElementById('input-bbox').value = ''; document.getElementById('input-bbox').value = '';
updateStatus(false); updateStatus(false);
redraw(); redraw();
} }
function openFullRes() {
if (currentFrameSrc) window.open(currentFrameSrc, '_blank');
}
function selectFrame(el, src) { function selectFrame(el, src) {
document.querySelectorAll('.frame-thumb').forEach(t => t.classList.remove('active')); document.querySelectorAll('.frame-thumb').forEach(t => t.classList.remove('active'));
el.classList.add('active'); el.classList.add('active');
currentFrameSrc = src; currentFrameSrc = src;
document.getElementById('input-frame').value = src.startsWith('/') ? src.slice(1) : src; document.getElementById('input-frame').value = src.startsWith('/') ? src.slice(1) : src;
clearRect(); clearRect();
loadImage(src); resetZoom();
img.src = src;
} }
// ── Image loading ──────────────────────────────────────────────────────
img.onload = function() {
const wrap = document.getElementById('canvas-wrap');
canvas.width = wrap.clientWidth;
canvas.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth);
vp = { scale: 1, tx: 0, ty: 0 };
document.getElementById('zoom-label').textContent = '100%';
redraw();
};
// ── Form validation ──────────────────────────────────────────────────── // ── Form validation ────────────────────────────────────────────────────
document.getElementById('ann-form').addEventListener('submit', function(e) { document.getElementById('ann-form').addEventListener('submit', function(e) {
if (!document.getElementById('input-bbox').value) { if (!document.getElementById('input-bbox').value) {
@@ -303,16 +453,17 @@
// ── Init ─────────────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────────────
{% if frames %} {% if frames %}
loadImage('/{{ frames[0] }}');
currentFrameSrc = '/{{ frames[0] }}'; currentFrameSrc = '/{{ frames[0] }}';
img.src = currentFrameSrc;
{% endif %} {% endif %}
// Handle window resize
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
if (!img.src) return; if (!img.complete || !img.naturalWidth) return;
const wrap = document.getElementById('canvas-wrap'); const wrap = document.getElementById('canvas-wrap');
canvas.width = wrap.clientWidth; canvas.width = wrap.clientWidth;
canvas.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth); canvas.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth);
vp = { scale: 1, tx: 0, ty: 0 };
document.getElementById('zoom-label').textContent = '100%';
redraw(); redraw();
}); });
</script> </script>
+52 -2
View File
@@ -179,7 +179,11 @@
<!-- Lightbox --> <!-- Lightbox -->
<div id="lightbox" onclick="closeLightboxIfOutside(event)"> <div id="lightbox" onclick="closeLightboxIfOutside(event)">
<span id="lb-close" onclick="closeLightbox()">×</span> <span id="lb-close" onclick="closeLightbox()">×</span>
<img id="lb-img" src="" alt=""> <div id="lb-zoom-hint" style="position:absolute;top:12px;left:50%;transform:translateX(-50%);color:#64748b;font-size:0.75rem;pointer-events:none;">
Molette pour zoomer · Glisser pour déplacer · <span id="lb-zoom-pct">100%</span>
&nbsp;·&nbsp;<a id="lb-fullres" href="#" target="_blank" style="color:#60a5fa;text-decoration:underline;">Ouvrir en plein écran</a>
</div>
<img id="lb-img" src="" alt="" style="transform-origin:center center;">
<div id="lb-nav"> <div id="lb-nav">
<button onclick="lbPrev()" class="btn-ghost px-4"></button> <button onclick="lbPrev()" class="btn-ghost px-4"></button>
<span id="lb-label" class="text-slate-400 text-sm"></span> <span id="lb-label" class="text-slate-400 text-sm"></span>
@@ -191,10 +195,53 @@
let lbFrames = {{ frames | tojson }}; let lbFrames = {{ frames | tojson }};
let lbIdx = 0; let lbIdx = 0;
// Lightbox zoom/pan state
let lbZ = 1, lbOx = 0, lbOy = 0, lbDrag = null;
function lbResetZoom() {
lbZ = 1; lbOx = 0; lbOy = 0;
const im = document.getElementById('lb-img');
im.style.transform = '';
im.style.cursor = '';
document.getElementById('lb-zoom-pct').textContent = '100%';
}
function lbApplyTransform() {
document.getElementById('lb-img').style.transform =
`translate(${lbOx}px,${lbOy}px) scale(${lbZ})`;
document.getElementById('lb-zoom-pct').textContent = Math.round(lbZ*100)+'%';
document.getElementById('lb-img').style.cursor = lbZ > 1 ? 'grab' : '';
}
document.getElementById('lightbox').addEventListener('wheel', e => {
if (!document.getElementById('lightbox').classList.contains('open')) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.3 : 1/1.3;
lbZ = Math.max(0.5, Math.min(20, lbZ * factor));
lbApplyTransform();
}, { passive: false });
document.getElementById('lb-img').addEventListener('mousedown', e => {
if (lbZ <= 1) return;
e.stopPropagation();
lbDrag = [e.clientX - lbOx, e.clientY - lbOy];
document.getElementById('lb-img').style.cursor = 'grabbing';
});
document.addEventListener('mousemove', e => {
if (!lbDrag) return;
lbOx = e.clientX - lbDrag[0];
lbOy = e.clientY - lbDrag[1];
lbApplyTransform();
});
document.addEventListener('mouseup', () => {
if (lbDrag) { lbDrag = null; if (lbZ > 1) document.getElementById('lb-img').style.cursor = 'grab'; }
});
function openLightbox(src, label) { function openLightbox(src, label) {
lbResetZoom();
document.getElementById('lb-img').src = src; document.getElementById('lb-img').src = src;
document.getElementById('lb-label').textContent = label; document.getElementById('lb-label').textContent = label;
// Find index in frames array document.getElementById('lb-fullres').href = src;
const rel = src.startsWith('/') ? src.slice(1) : src; const rel = src.startsWith('/') ? src.slice(1) : src;
lbIdx = lbFrames.indexOf(rel); lbIdx = lbFrames.indexOf(rel);
document.getElementById('lightbox').classList.add('open'); document.getElementById('lightbox').classList.add('open');
@@ -202,6 +249,7 @@
function closeLightbox() { function closeLightbox() {
document.getElementById('lightbox').classList.remove('open'); document.getElementById('lightbox').classList.remove('open');
lbResetZoom();
} }
function closeLightboxIfOutside(e) { function closeLightboxIfOutside(e) {
@@ -211,7 +259,9 @@
function lbGo(idx) { function lbGo(idx) {
if (lbFrames.length === 0) return; if (lbFrames.length === 0) return;
lbIdx = ((idx % lbFrames.length) + lbFrames.length) % lbFrames.length; lbIdx = ((idx % lbFrames.length) + lbFrames.length) % lbFrames.length;
lbResetZoom();
document.getElementById('lb-img').src = '/' + lbFrames[lbIdx]; document.getElementById('lb-img').src = '/' + lbFrames[lbIdx];
document.getElementById('lb-fullres').href = '/' + lbFrames[lbIdx];
document.getElementById('lb-label').textContent = 'Frame #' + (lbIdx + 1) + (lbIdx === 0 ? ' ★' : ''); document.getElementById('lb-label').textContent = 'Frame #' + (lbIdx + 1) + (lbIdx === 0 ? ' ★' : '');
} }