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}" + )