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 a2519fc..cffa4db 100644
--- a/src/frontend/flask_app/static/js/dxf_recipe_wizard.js
+++ b/src/frontend/flask_app/static/js/dxf_recipe_wizard.js
@@ -57,6 +57,11 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
+ // #113: zoom delle due anteprime (img = immagine caricata/acquisita, dxf = disegno); w,h = misura del contenuto (px immagine; mm del viewBox per il DXF)
+ views: { img: { zoom: 1, fit: 1, w: 0, h: 0 }, dxf: { zoom: 1, fit: 1, w: 0, h: 0 } },
+ viewPanKey: '', // #113: anteprima in trascinamento ('' = nessuna)
+ _viewPan: null, // #113: {k, x, y, left, top} al pointerdown
+ _viewRO: {}, // #113: ResizeObserver per chiave, rifanno Adatta se il viewport cambia larghezza
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
init() {
@@ -200,6 +205,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.imageSrc = file ? URL.createObjectURL(file) : '';
this.imagePx = null;
this.imageBroken = false;
+ this.vReset('img'); // #113: nuova immagine, vista da rifare
this.result = null;
this.tested = false; // #104
this.resPx = null; // #104
@@ -211,10 +217,13 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
onImagePreviewLoad(el) {
this.imageBroken = false;
this.imagePx = el && el.naturalWidth ? { w: el.naturalWidth, h: el.naturalHeight } : null;
+ if (this.imagePx) this.vSetContent('img', this.imagePx.w, this.imagePx.h); // #113: Adatta al caricamento
+ else this.vReset('img');
},
onImagePreviewError() {
this.imageBroken = !!this.imageSrc;
this.imagePx = null;
+ this.vReset('img'); // #113
},
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
get imagePreviewUrl() { return this.imageSrc; },
@@ -247,6 +256,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
async loadDxfPreview() {
const seq = ++this._dxfPreviewSeq;
this.dxfPreview = null;
+ this.vReset('dxf'); // #113: nuova anteprima in arrivo
this.dxfPreviewError = '';
this.dxfPreviewBusy = false;
if (!this.dxfFile) return; // file tolto: tutto azzerato
@@ -260,7 +270,11 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
const body = await resp.json().catch(() => ({}));
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
- else this.dxfPreview = body;
+ else {
+ this.dxfPreview = body;
+ const vb = this.dxfPreviewViewBox().split(' ').map(Number); // #113: misura del viewBox (mm) per lo zoom
+ this.vSetContent('dxf', vb[2], vb[3]);
+ }
} catch (e) {
if (seq !== this._dxfPreviewSeq) return;
this.dxfPreviewError = this.msgs.requestFailed || String(e);
@@ -463,6 +477,82 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.fitZoom = z;
vp.scrollLeft = 0; vp.scrollTop = 0;
},
+ // #113: zoom per chiave ('img' | 'dxf'), stessa logica di #104 ma con stato in views[k]. Viewport fisso di 240px.
+ _vpEl(k) { return this.$refs ? this.$refs[k === 'img' ? 'vpImg' : 'vpDxf'] : null; },
+ _vLimits(k) {
+ const v = this.views[k];
+ // img: pixel nativi 5 %..800 %; dxf: da meta' a 50 volte Adatta
+ return k === 'img' ? [0.05, 8] : [v.fit * 0.5, v.fit * 50];
+ },
+ vPct(k) {
+ const v = this.views[k];
+ return Math.round((k === 'img' ? v.zoom : (v.fit ? v.zoom / v.fit : 1)) * 100) + ' %';
+ },
+ vW(k) { const v = this.views[k]; return v.w * v.zoom; },
+ vH(k) { const v = this.views[k]; return v.h * v.zoom; },
+ vCanPan(k) { const v = this.views[k]; return v.w > 0 && v.zoom > v.fit * 1.0001; },
+ vCursor(k) { return this.vCanPan(k) ? (this.viewPanKey === k ? 'grabbing' : 'grab') : 'default'; },
+ // #113: nessun contenuto: zoom di nuovo a 1 e misura azzerata (il wrapper sparisce)
+ vReset(k) { this.views[k] = { zoom: 1, fit: 1, w: 0, h: 0 }; },
+ // #113: nuovo contenuto: vista da Adatta (appena il viewport ha una larghezza) e osservatore di resize
+ vSetContent(k, w, h) {
+ if (!(w > 0) || !(h > 0)) { this.vReset(k); return; }
+ this.views[k] = { zoom: 1, fit: 1, w, h };
+ const run = () => { this._vObserve(k); this.vFit(k); };
+ if (typeof this.$nextTick === 'function') this.$nextTick(run); else run();
+ },
+ // #113: se il viewport era nascosto (larghezza 0) o cambia larghezza, rifa Adatta finche' la vista e' ad Adatta
+ _vObserve(k) {
+ const vp = this._vpEl(k);
+ if (!vp || this._viewRO[k] || typeof ResizeObserver === 'undefined') return;
+ this._viewRO[k] = new ResizeObserver(() => {
+ const v = this.views[k];
+ if (v.w > 0 && Math.abs(v.zoom - v.fit) < 1e-9 * Math.max(1, v.fit)) this.vFit(k);
+ });
+ this._viewRO[k].observe(vp);
+ },
+ vFit(k) {
+ const vp = this._vpEl(k), v = this.views[k];
+ if (!vp || !v.w || !v.h || !vp.clientWidth) return; // nascosto o vuoto: niente
+ let z = Math.min(vp.clientWidth / v.w, 240 / v.h);
+ if (k === 'img') z = Math.min(8, Math.max(0.05, z));
+ v.zoom = z;
+ v.fit = z;
+ vp.scrollLeft = 0; vp.scrollTop = 0;
+ },
+ vZoomBy(k, f) {
+ const vp = this._vpEl(k), v = this.views[k];
+ if (!v.w) return;
+ const old = v.zoom, lim = this._vLimits(k);
+ const z = Math.min(lim[1], Math.max(lim[0], old * f));
+ if (z === old) return;
+ // centro visibile invariato, come zoomBy (#104)
+ let cx = 0, cy = 0;
+ if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; }
+ v.zoom = z;
+ if (vp && typeof this.$nextTick === 'function') this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
+ },
+ vWheel(k, ev) {
+ if (!ev.ctrlKey) return;
+ ev.preventDefault();
+ this.vZoomBy(k, ev.deltaY < 0 ? 1.25 : 1 / 1.25);
+ },
+ vPanDown(k, ev) {
+ const vp = this._vpEl(k);
+ if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
+ if (ev.preventDefault) ev.preventDefault();
+ this._viewPan = { k, x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
+ this.viewPanKey = k;
+ try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
+ },
+ vPanMove(k, ev) {
+ const vp = this._vpEl(k), p = this._viewPan;
+ if (!p || p.k !== k || !vp) return;
+ vp.scrollLeft = p.left - (ev.clientX - p.x);
+ vp.scrollTop = p.top - (ev.clientY - p.y);
+ },
+ vPanEnd() { this._viewPan = null; this.viewPanKey = ''; },
+
// #104: Ctrl+rotella = zoom
onWheel(ev) {
if (!ev.ctrlKey) return;
diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html
index a2452b9..140cb47 100644
--- a/src/frontend/flask_app/templates/maker/task_editor.html
+++ b/src/frontend/flask_app/templates/maker/task_editor.html
@@ -730,22 +730,58 @@
-
![]()
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
{{ _('Anteprima non disponibile per questo formato') }}
{# #104: resta a destra anche senza anteprima immagine #}
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/frontend/flask_app/tests/js/test_dxf_zoom.js b/src/frontend/flask_app/tests/js/test_dxf_zoom.js
new file mode 100644
index 0000000..54bd107
--- /dev/null
+++ b/src/frontend/flask_app/tests/js/test_dxf_zoom.js
@@ -0,0 +1,133 @@
+/**
+ * #113: zoom, Adatta e pan sulle anteprime del wizard (immagine e DXF), con plain node.
+ *
+ * Run from src/frontend/flask_app: node tests/js/test_dxf_zoom.js
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+global.document = { documentElement: { lang: 'it' } };
+const src = fs.readFileSync(
+ path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
+);
+eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
+
+let failures = 0;
+function check(label, cond, extra) {
+ if (cond) console.log('ok - ' + label);
+ else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
+}
+const near = (a, b) => Math.abs(a - b) < 1e-9 * Math.max(1, Math.abs(b));
+
+// Wizard con viewport finti e $nextTick sincrono
+function make(cw) {
+ const w = dxfRecipeWizard(1, () => 'tok', {});
+ const vp = () => ({ clientWidth: cw, clientHeight: 240, scrollLeft: 5, scrollTop: 5 });
+ w.$refs = { vpImg: vp(), vpDxf: vp() };
+ w.$nextTick = fn => fn();
+ return w;
+}
+
+// img: viewport 400 x 240, immagine 2856 x 2848
+let w = make(400);
+w.vSetContent('img', 2856, 2848);
+check('#113 img: Adatta = 240/2848', near(w.views.img.zoom, 240 / 2848) && near(w.views.img.fit, 240 / 2848), String(w.views.img.zoom));
+check('#113 img: scroll azzerato dopo Adatta', w.$refs.vpImg.scrollLeft === 0 && w.$refs.vpImg.scrollTop === 0);
+check('#113 img: percentuale = zoom*100', w.vPct('img') === Math.round(240 / 2848 * 100) + ' %', w.vPct('img'));
+const z0 = w.views.img.zoom;
+w.vZoomBy('img', 1.25);
+check('#113 img: zoomBy moltiplica', near(w.views.img.zoom, z0 * 1.25));
+check('#113 img: dopo zoom si puo trascinare', w.vCanPan('img') === true && w.vCursor('img') === 'grab');
+w.vZoomBy('img', 1e6);
+check('#113 img: limite alto 8', w.views.img.zoom === 8);
+w.vZoomBy('img', 1e-9);
+check('#113 img: limite basso 0.05', w.views.img.zoom === 0.05);
+w.vFit('img');
+check('#113 img: Adatta torna al fit', near(w.views.img.zoom, z0) && w.vCanPan('img') === false);
+check('#113 img: l\'altra vista non si muove', w.views.dxf.w === 0 && w.views.dxf.zoom === 1);
+check('#113 img: wrapper w*zoom', near(w.vW('img'), 2856 * z0) && near(w.vH('img'), 2848 * z0));
+
+// dxf: viewBox 20 x 10 mm, viewport 400 x 240 -> fit = min(20, 24) = 20
+w = make(400);
+w.vSetContent('dxf', 20, 10);
+check('#113 dxf: fit = min(vpW/w, 240/h)', near(w.views.dxf.fit, 20), String(w.views.dxf.fit));
+check('#113 dxf: dopo Adatta 100 %', w.vPct('dxf') === '100 %', w.vPct('dxf'));
+w.vZoomBy('dxf', 2);
+check('#113 dxf: zoom 2x = 200 %', w.vPct('dxf') === '200 %', w.vPct('dxf'));
+w.vZoomBy('dxf', 1e9);
+check('#113 dxf: si ferma a fit*50', near(w.views.dxf.zoom, 20 * 50) && w.vPct('dxf') === '5000 %', String(w.views.dxf.zoom));
+w.vZoomBy('dxf', 1e-9);
+check('#113 dxf: si ferma a fit*0.5', near(w.views.dxf.zoom, 10) && w.vPct('dxf') === '50 %', String(w.views.dxf.zoom));
+
+// vFit con viewport largo 0 (nascosto): stato invariato
+w = make(0);
+w.vSetContent('img', 1000, 500);
+check('#113 clientWidth 0: nessun fit, stato invariato', w.views.img.zoom === 1 && w.views.img.fit === 1 && w.views.img.w === 1000);
+w.$refs.vpImg.clientWidth = 500;
+w.vFit('img');
+check('#113 viewport visibile: il fit parte', near(w.views.img.fit, 0.48), String(w.views.img.fit));
+
+// Reset: nuova immagine / DXF tolto
+w.vReset('img');
+check('#113 reset: contenuto e zoom azzerati', w.views.img.w === 0 && w.views.img.zoom === 1 && w.vCanPan('img') === false);
+w.vZoomBy('img', 2);
+check('#113 zoomBy senza contenuto: ignorato', w.views.img.zoom === 1);
+
+// Integrazione: onImagePreviewLoad e _setImage
+global.URL.createObjectURL = () => 'blob:x';
+global.URL.revokeObjectURL = () => {};
+w = make(400);
+w._setImage({ name: 'a.bmp' }, 'file');
+w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 });
+check('#113 onImagePreviewLoad: Adatta all\'arrivo', near(w.views.img.zoom, 0.5) && w.views.img.w === 800, String(w.views.img.zoom));
+w._setImage({ name: 'b.bmp' }, 'file');
+check('#113 _setImage: vista azzerata', w.views.img.w === 0 && w.views.img.zoom === 1);
+w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 });
+w.onImagePreviewError();
+check('#113 errore
: vista azzerata', w.views.img.w === 0);
+
+// Wheel: solo con Ctrl
+w = make(400);
+w.vSetContent('dxf', 20, 10);
+let prevented = false;
+w.vWheel('dxf', { ctrlKey: false, deltaY: -1, preventDefault() { prevented = true; } });
+check('#113 rotella senza Ctrl: niente zoom', w.views.dxf.zoom === 20 && !prevented);
+w.vWheel('dxf', { ctrlKey: true, deltaY: -1, preventDefault() { prevented = true; } });
+check('#113 Ctrl+rotella: zoom avanti', near(w.views.dxf.zoom, 25) && prevented);
+
+// Pan
+w = make(400);
+w.vSetContent('dxf', 20, 10);
+w.vZoomBy('dxf', 2);
+const vp = w.$refs.vpDxf;
+vp.scrollLeft = 100; vp.scrollTop = 50; vp.setPointerCapture = () => {};
+w.vPanDown('dxf', { pointerType: 'mouse', button: 0, clientX: 200, clientY: 100, pointerId: 1 });
+w.vPanMove('dxf', { clientX: 150, clientY: 90 });
+check('#113 pan: scroll di quanto si muove il puntatore', vp.scrollLeft === 150 && vp.scrollTop === 60, vp.scrollLeft + ',' + vp.scrollTop);
+check('#113 pan: cursore grabbing', w.vCursor('dxf') === 'grabbing');
+w.vPanEnd();
+check('#113 pan finito', w.viewPanKey === '' && w._viewPan === null);
+
+// Integrazione DXF: loadDxfPreview imposta la misura del viewBox e fa Adatta; file tolto azzera
+(async () => {
+ const PREVIEW = {
+ contorni: [[[0, 0], [20, 0], [20, 10]]],
+ bbox: { xmin: 0, ymin: 0, xmax: 20, ymax: 10 },
+ larghezza_mm: 20, altezza_mm: 10, entita: 1, punti: 3,
+ };
+ global.FormData = class { append() {} };
+ global.fetch = async () => ({ ok: true, json: async () => PREVIEW });
+ const wd = make(400);
+ wd.dxfFile = { name: 'a.dxf' };
+ await wd.loadDxfPreview();
+ const vb = wd.dxfPreviewViewBox().split(' ').map(Number);
+ check('#113 loadDxfPreview: misura = viewBox', near(wd.views.dxf.w, vb[2]) && near(wd.views.dxf.h, vb[3]), JSON.stringify(wd.views.dxf));
+ check('#113 loadDxfPreview: Adatta = 100 %', wd.vPct('dxf') === '100 %' && wd.views.dxf.fit > 1);
+ wd.dxfFile = null;
+ await wd.loadDxfPreview();
+ check('#113 DXF tolto: vista azzerata', wd.views.dxf.w === 0 && wd.views.dxf.zoom === 1);
+
+ if (failures) { console.log(failures + ' test falliti'); process.exit(1); }
+ console.log('tutti i test #113 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 1e7a0f3..931b490 100644
--- a/src/frontend/flask_app/tests/test_dxf_recipe_routes.py
+++ b/src/frontend/flask_app/tests/test_dxf_recipe_routes.py
@@ -357,5 +357,10 @@ class TestPages:
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
+ # #113: zoom, Adatta e pan anche sulle due anteprime (immagine e DXF), per chiave
+ assert 'x-ref="vpImg"' in html and 'x-ref="vpDxf"' in html
+ assert "vFit('img')" in html and "vFit('dxf')" in html
+ assert "vZoomBy('img', 1.25)" in html and "vZoomBy('dxf', 1.25)" in html
+ assert "vPanDown('img', $event)" in html and "vWheel('dxf', $event)" in html
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
diff --git a/src/frontend/flask_app/tests/test_dxf_zoom_js.py b/src/frontend/flask_app/tests/test_dxf_zoom_js.py
new file mode 100644
index 0000000..f830940
--- /dev/null
+++ b/src/frontend/flask_app/tests/test_dxf_zoom_js.py
@@ -0,0 +1,35 @@
+"""Runs the plain-node DXF wizard zoom test (tests/js/test_dxf_zoom.js).
+
+Same thin pytest wrapper pattern as test_dxf_preview_js.py: shells out to
+``node`` and asserts the script's own exit code. Skips (does not fail) when
+node is not installed.
+
+Covers gitea:Adriano/TieMeasureFlow#113.
+"""
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+FLASK_APP_DIR = Path(__file__).resolve().parent.parent
+JS_TEST = Path(__file__).resolve().parent / "js" / "test_dxf_zoom.js"
+
+
+def test_dxf_zoom_js_behaviour():
+ node = shutil.which("node")
+ if not node:
+ pytest.skip("node non installato: test JS dello zoom del wizard saltato")
+
+ result = subprocess.run(
+ [node, str(JS_TEST)],
+ cwd=str(FLASK_APP_DIR),
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+
+ assert result.returncode == 0, (
+ f"tests/js/test_dxf_zoom.js fallito (exit {result.returncode})\n"
+ f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
+ )