maker: zoom in sovrimpressione, pan col puntatore e viewport che segue l'immagine (Refs #104)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
2026-10-25 16:42:42 +01:00
parent 34e1b042f5
commit 02f1481fe5
4 changed files with 82 additions and 14 deletions
@@ -41,6 +41,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file' imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file'
tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato
zoom: 1, // #104: scala dell'immagine di prova (1 = pixel nativi) zoom: 1, // #104: scala dell'immagine di prova (1 = pixel nativi)
fitZoom: 1, // #104: zoom di Adatta (sotto o pari a questo il viewport segue l'immagine)
panning: false, // #104: trascinamento in corso
_pan: null, // #104: {x, y, left, top} al pointerdown
resPx: null, // #104: {w, h} in pixel dell'immagine di prova (per la dimensione del wrapper) resPx: null, // #104: {w, h} in pixel dell'immagine di prova (per la dimensione del wrapper)
runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione) runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
runBandMm: null, // #104: banda usata nell'ultima Prova runBandMm: null, // #104: banda usata nell'ultima Prova
@@ -412,6 +415,32 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
get zoomPct() { return Math.round(this.zoom * 100) + ' %'; }, get zoomPct() { return Math.round(this.zoom * 100) + ' %'; },
get zoomW() { return this.resPx ? this.resPx.w * this.zoom : 0; }, get zoomW() { return this.resPx ? this.resPx.w * this.zoom : 0; },
get zoomH() { return this.resPx ? this.resPx.h * this.zoom : 0; }, get zoomH() { return this.resPx ? this.resPx.h * this.zoom : 0; },
// #104: altezza massima del viewport = min(70vh, 520px)
_maxVpH() { return Math.min(0.7 * (typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 800), 520); },
// #104: con zoom <= Adatta il viewport e' alto come l'immagine scalata (min 200px), altrimenti al massimo
get vpHeight() {
const max = this._maxVpH();
if (!this.resPx || this.zoom > this.fitZoom * 1.0001) return max;
return Math.min(max, Math.max(200, this.zoomH));
},
// #104: l'immagine e' piu' grande dello spazio: si puo' trascinare
get canPan() { return !!this.resPx && this.zoom > this.fitZoom * 1.0001; },
get panCursor() { return this.canPan ? (this.panning ? 'grabbing' : 'grab') : 'default'; },
// #104: pan col puntatore (mouse e touch): scroll del viewport di quanto si muove il puntatore
onPanDown(ev) {
const vp = this.$refs.viewport;
if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
this.panning = true;
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
},
onPanMove(ev) {
const vp = this.$refs.viewport;
if (!this._pan || !vp) return;
vp.scrollLeft = this._pan.left - (ev.clientX - this._pan.x);
vp.scrollTop = this._pan.top - (ev.clientY - this._pan.y);
},
onPanEnd() { this._pan = null; this.panning = false; },
zoomBy(f) { zoomBy(f) {
const vp = this.$refs.viewport; const vp = this.$refs.viewport;
const old = this.zoom; const old = this.zoom;
@@ -423,11 +452,13 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.zoom = z; this.zoom = z;
if (vp) this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; }); if (vp) this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
}, },
// #104: Fit = tutta l'immagine nel viewport // #104: Fit = tutta l'immagine nel viewport (altezza massima, non quella corrente che segue l'immagine)
fit() { fit() {
const vp = this.$refs.viewport, img = this.$refs.img; const vp = this.$refs.viewport, img = this.$refs.img;
if (!vp || !img || !img.naturalWidth || !vp.clientWidth || !vp.clientHeight) return; if (!vp || !img || !img.naturalWidth || !vp.clientWidth) return;
this.zoom = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, vp.clientHeight / img.naturalHeight))); const z = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, this._maxVpH() / img.naturalHeight)));
this.zoom = z;
this.fitZoom = z;
vp.scrollLeft = 0; vp.scrollTop = 0; vp.scrollLeft = 0; vp.scrollTop = 0;
}, },
// #104: Ctrl+rotella = zoom // #104: Ctrl+rotella = zoom
@@ -776,20 +776,27 @@
<!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra --> <!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
<div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #} <div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
<div> <div>
<div class="flex items-center gap-2 mb-1"> <!-- #104: barra zoom in sovrimpressione, fuori dal contenuto scorrevole -->
<button type="button" class="btn btn-secondary text-xs" @click="zoomBy(1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button> <div class="relative">
<button type="button" class="btn btn-secondary text-xs" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button> <div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
<button type="button" class="btn btn-secondary text-xs" @click="fit()">{{ _('Adatta') }}</button> style="background: rgba(0,0,0,.55); color: #fff;">
<span class="text-xs text-[var(--text-muted)]" x-text="zoomPct"></span> <button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button>
</div> <button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
<div x-ref="viewport" @wheel="onWheel($event)" <button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="fit()">{{ _('Adatta') }}</button>
class="rounded border border-[var(--border-color)]" <span class="text-xs px-1" x-text="zoomPct"></span>
style="overflow: auto; height: min(70vh, 520px); background: var(--bg-primary);"> </div>
<!-- #104: pan col puntatore (mouse e dito), cursore grab; altezza che segue l'immagine finche' zoom <= Adatta -->
<div x-ref="viewport" @wheel="onWheel($event)"
@pointerdown="onPanDown($event)" @pointermove="onPanMove($event)"
@pointerup="onPanEnd()" @pointercancel="onPanEnd()"
class="rounded border border-[var(--border-color)]"
:style="'overflow:auto;touch-action:none;user-select:none;background:var(--bg-primary);height:' + vpHeight + 'px;cursor:' + panCursor">
<div class="relative" :style="zoomW ? ('width:' + zoomW + 'px;height:' + zoomH + 'px') : 'width:100%'"> <div class="relative" :style="zoomW ? ('width:' + zoomW + 'px;height:' + zoomH + 'px') : 'width:100%'">
<img x-ref="img" :src="imageSrc" alt="" class="block" style="width: 100%; height: 100%;" <img x-ref="img" :src="imageSrc" alt="" class="block" draggable="false" style="width: 100%; height: 100%; user-select: none;"
@load="onResultImgLoad()"> @load="onResultImgLoad()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas> <canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div> </div>
</div>
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -163,7 +163,7 @@ const PREVIEW = {
check('#104 fit limitato dalla larghezza', Math.abs(w5.zoom - 0.25) < 1e-9, String(w5.zoom)); check('#104 fit limitato dalla larghezza', Math.abs(w5.zoom - 0.25) < 1e-9, String(w5.zoom));
w5.$refs.img = { naturalWidth: 1000, naturalHeight: 2000 }; w5.$refs.img = { naturalWidth: 1000, naturalHeight: 2000 };
w5.fit(); w5.fit();
check('#104 fit limitato dall altezza', Math.abs(w5.zoom - 0.2) < 1e-9, String(w5.zoom)); check('#104 fit limitato dall altezza massima (520 senza window)', Math.abs(w5.zoom - 0.26) < 1e-9, String(w5.zoom));
w5.$refs.viewport.clientWidth = 0; w5.zoom = 1; w5.fit(); w5.$refs.viewport.clientWidth = 0; w5.zoom = 1; w5.fit();
check('#104 fit con viewport nascosto non cambia', w5.zoom === 1); check('#104 fit con viewport nascosto non cambia', w5.zoom === 1);
@@ -267,6 +267,35 @@ const PREVIEW = {
await w11.run(true); await w11.run(true);
check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true); check('#105 dopo Salva: existing letto e non ricompilato', w11.existing && w11.existing.band_mm === 0.3 && w11._existingApplied === true);
// #104: pan col puntatore
const wpan = dxfRecipeWizard(1, () => 'tok', {});
const vppan = { scrollLeft: 100, scrollTop: 100, clientWidth: 500, clientHeight: 400, setPointerCapture() {} };
wpan.$refs = { viewport: vppan };
wpan.onPanDown({ clientX: 200, clientY: 200, pointerId: 1, pointerType: 'mouse', button: 0 });
wpan.onPanMove({ clientX: 170, clientY: 180 });
check('#104 pan: pointermove (-30,-20) -> scroll +30/+20', vppan.scrollLeft === 130 && vppan.scrollTop === 120, vppan.scrollLeft + ',' + vppan.scrollTop);
check('#104 pan: grabbing durante il trascinamento', wpan.panning === true);
wpan.onPanEnd();
wpan.onPanMove({ clientX: 0, clientY: 0 });
check('#104 pan: dopo pointerup non scrolla', vppan.scrollLeft === 130 && wpan.panning === false);
wpan.onPanDown({ clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse', button: 2 });
check('#104 pan: il tasto destro non parte', wpan.panning === false);
// #104: altezza del viewport
global.window = { innerHeight: 1000 }; // max = min(700, 520) = 520
wpan.resPx = { w: 2000, h: 1000 };
wpan.$refs = { viewport: { clientWidth: 500, scrollLeft: 0, scrollTop: 0 }, img: { naturalWidth: 2000, naturalHeight: 1000 } };
wpan.fit(); // zoom = min(500/2000, 520/1000) = 0.25
check('#104 fit usa l altezza massima', Math.abs(wpan.zoom - 0.25) < 1e-9 && wpan.fitZoom === wpan.zoom, String(wpan.zoom));
check('#104 altezza a Fit = immagine scalata', wpan.vpHeight === 250 && wpan.canPan === false, String(wpan.vpHeight)); // zoomH = 250
wpan.resPx = { w: 2000, h: 400 }; // zoomH = 100 -> minimo 200
check('#104 altezza minima 200', wpan.vpHeight === 200, String(wpan.vpHeight));
wpan.zoom = 1;
check('#104 con zoom > Adatta altezza massima e pan attivo', wpan.vpHeight === 520 && wpan.canPan === true && wpan.panCursor === 'grab');
global.window = { innerHeight: 500 }; // 70vh = 350
check('#104 altezza massima = 70vh se minore di 520', wpan.vpHeight === 350, String(wpan.vpHeight));
delete global.window;
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); } if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
console.log('tutto ok'); console.log('tutto ok');
})(); })();
@@ -354,6 +354,7 @@ class TestPages:
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
assert "imageSource === 'file'" in html and "zoomBy(" in html and "fit()" in html # #104: mm/px solo da file, zoom e Fit assert "imageSource === 'file'" in html and "zoomBy(" in html and "fit()" in html # #104: mm/px solo da file, zoom e Fit
assert 'onPanDown($event)' in html # #104: pan col puntatore
assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine
assert "existing ? msgs.editLabel : msgs.createLabel" in html and "Modifica ricetta da DXF" in html # #105: etichetta dinamica assert "existing ? msgs.editLabel : msgs.createLabel" in html and "Modifica ricetta da DXF" in html # #105: etichetta dinamica
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html