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:
co-authored by
Claude Sonnet 4.6
parent
fd52942bd1
commit
01c5363649
+206
-55
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
· <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 ? ' ★' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user