From 02f1481fe5adc3f88b0cc94f7df002aff028f09a Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sun, 25 Oct 2026 16:42:42 +0100 Subject: [PATCH] maker: zoom in sovrimpressione, pan col puntatore e viewport che segue l'immagine (Refs #104) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB --- .../flask_app/static/js/dxf_recipe_wizard.js | 37 +++++++++++++++++-- .../templates/maker/task_editor.html | 27 +++++++++----- .../flask_app/tests/js/test_dxf_preview.js | 31 +++++++++++++++- .../flask_app/tests/test_dxf_recipe_routes.py | 1 + 4 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/frontend/flask_app/static/js/dxf_recipe_wizard.js b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js index 4c07d70..00e2c38 100644 --- a/src/frontend/flask_app/static/js/dxf_recipe_wizard.js +++ b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js @@ -41,6 +41,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) { 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 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) runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione) 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 zoomW() { return this.resPx ? this.resPx.w * 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) { const vp = this.$refs.viewport; const old = this.zoom; @@ -423,11 +452,13 @@ function dxfRecipeWizard(taskId, csrf, msgs) { this.zoom = z; 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() { const vp = this.$refs.viewport, img = this.$refs.img; - if (!vp || !img || !img.naturalWidth || !vp.clientWidth || !vp.clientHeight) return; - this.zoom = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, vp.clientHeight / img.naturalHeight))); + if (!vp || !img || !img.naturalWidth || !vp.clientWidth) return; + 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; }, // #104: Ctrl+rotella = zoom diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html index 550a89c..afe7beb 100644 --- a/src/frontend/flask_app/templates/maker/task_editor.html +++ b/src/frontend/flask_app/templates/maker/task_editor.html @@ -776,20 +776,27 @@
{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
-
- - - - -
-
+ +
+
+ + + + +
+ +
-
+
diff --git a/src/frontend/flask_app/tests/js/test_dxf_preview.js b/src/frontend/flask_app/tests/js/test_dxf_preview.js index cac6127..8a4d1df 100644 --- a/src/frontend/flask_app/tests/js/test_dxf_preview.js +++ b/src/frontend/flask_app/tests/js/test_dxf_preview.js @@ -163,7 +163,7 @@ const PREVIEW = { 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.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(); check('#104 fit con viewport nascosto non cambia', w5.zoom === 1); @@ -267,6 +267,35 @@ const PREVIEW = { await w11.run(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); } console.log('tutto ok'); })(); diff --git a/src/frontend/flask_app/tests/test_dxf_recipe_routes.py b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py index a5b238c..f4fc24e 100644 --- a/src/frontend/flask_app/tests/test_dxf_recipe_routes.py +++ b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py @@ -354,6 +354,7 @@ class TestPages: 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 "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 "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