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:
@@ -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,22 +776,29 @@
|
|||||||
<!-- #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') }}">−</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') }}">−</button>
|
||||||
|
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
||||||
|
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="fit()">{{ _('Adatta') }}</button>
|
||||||
|
<span class="text-xs px-1" x-text="zoomPct"></span>
|
||||||
</div>
|
</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)"
|
<div x-ref="viewport" @wheel="onWheel($event)"
|
||||||
|
@pointerdown="onPanDown($event)" @pointermove="onPanMove($event)"
|
||||||
|
@pointerup="onPanEnd()" @pointercancel="onPanEnd()"
|
||||||
class="rounded border border-[var(--border-color)]"
|
class="rounded border border-[var(--border-color)]"
|
||||||
style="overflow: auto; height: min(70vh, 520px); background: var(--bg-primary);">
|
: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">
|
||||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||||
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user