<tmf-viewer>: visualizzatore immagine standard, tmfSaveBlob e tmfSvgToPngBlob
Refs #122 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* image_viewer.js - #122: <tmf-viewer>, il visualizzatore immagine standard di TieMeasureFlow.
|
||||
*
|
||||
* Cosa fa: Custom Element in JS puro (senza Alpine ne' Tailwind) con zoom -/+, Adatta, Salva, percentuale,
|
||||
* Ctrl+rotella intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette.
|
||||
* Sostituisce le quattro implementazioni di zoom di prima (pannello operatore #115, wizard DXF #113 e #104,
|
||||
* pagina camera #111). Light DOM, stile iniettato una volta nel <head> (<style id="tmf-viewer-css">).
|
||||
* Come si usa: <script> vision_overlay.js e save_image.js, poi image_viewer.js; poi
|
||||
* <tmf-viewer src="..." width="1920" height="1080" save-name="task_14" fit-height></tmf-viewer>
|
||||
* Attributi: src, width, height (px logici), save-name, save-src, no-save, fit-height,
|
||||
* label-fit, label-save, title-in, title-out, title-save.
|
||||
* Senza src, un figlio <svg> (DXF) va nello stage al 100 % (modo contenuto; Salva lo rasterizza in PNG).
|
||||
* Proprieta': overlay (forme vs-tools -> VisionOverlay.svg), labels ([{key,x,y,text,cls}] in px logici),
|
||||
* zoom (sola lettura); metodi fit(), zoomBy(f). Eventi (bubbling): viewer-load {width,height}, viewer-zoom {zoom,fit}.
|
||||
* In Alpine: :src="...", x-effect="$el.overlay = ...; $el.labels = ...".
|
||||
* Dipende da: window.VisionOverlay (facoltativo, per l'overlay), tmfSaveImage/tmfSaveBlob/tmfSvgToPngBlob di save_image.js
|
||||
* (per Salva). Il modulo si carica anche in node (require): esporta solo TmfViewerMath, funzioni pure.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TAG = 'tmf-viewer';
|
||||
var STEP = 1.25; // #122: passo dei pulsanti +/- e della rotella (come #111 e #115)
|
||||
|
||||
// #122: matematica pura dello zoom, testata in node senza browser
|
||||
var TmfViewerMath = {
|
||||
// zoom che fa stare tutta l'immagine (w x h) nel box (boxW x boxH); 0 se non calcolabile. boxH puo' essere Infinity
|
||||
fitZoom: function (boxW, boxH, w, h) {
|
||||
if (!(boxW > 0) || !(boxH > 0) || !(w > 0) || !(h > 0)) return 0;
|
||||
var f = Math.min(boxW / w, boxH / h);
|
||||
return isFinite(f) && f > 0 ? f : 0;
|
||||
},
|
||||
// zoom ristretto a [fit * 0.5, max(8, fit)] (come #115)
|
||||
clampZoom: function (z, fit) {
|
||||
var lo = fit * 0.5, hi = Math.max(8, fit);
|
||||
return Math.min(hi, Math.max(lo, z));
|
||||
},
|
||||
// nuovo scroll che tiene fermo il punto (px, py) del viewport passando dallo zoom z0 a z1
|
||||
zoomAround: function (z0, z1, scrollL, scrollT, px, py) {
|
||||
var k = z1 / z0;
|
||||
return { scrollLeft: (scrollL + px) * k - px, scrollTop: (scrollT + py) * k - py };
|
||||
},
|
||||
pct: function (z) { return Math.round(z * 100) + ' %'; },
|
||||
};
|
||||
|
||||
var CSS = [
|
||||
'tmf-viewer{display:block;position:relative;overflow:hidden;background:#1f2937;min-width:0}',
|
||||
'tmf-viewer *{box-sizing:border-box}',
|
||||
'tmf-viewer [hidden]{display:none!important}',
|
||||
'tmf-viewer .tmfv-vp{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;display:flex}',
|
||||
'tmf-viewer[fit-height] .tmfv-vp{position:relative;width:100%}',
|
||||
'tmf-viewer .tmfv-stage{position:relative;flex:none;margin:auto;user-select:none;-webkit-user-select:none}',
|
||||
'tmf-viewer .tmfv-img{display:block;width:100%;height:100%;max-width:none;-webkit-user-drag:none}',
|
||||
'tmf-viewer .tmfv-stage>svg:not(.tmfv-ov){display:block;width:100%;height:100%}',
|
||||
'tmf-viewer .tmfv-ov{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible}',
|
||||
'tmf-viewer .tmfv-labels{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}',
|
||||
'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:600 12px/1.3 system-ui,sans-serif;',
|
||||
'color:#fff;background:rgba(0,0,0,.6);padding:1px 5px;border-radius:3px;pointer-events:none}',
|
||||
'tmf-viewer .tmfv-bar{position:absolute;top:6px;right:6px;z-index:3;display:flex;align-items:center;gap:2px;padding:2px;',
|
||||
'background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
|
||||
'tmf-viewer .tmfv-bar button{appearance:none;border:0;border-radius:4px;background:transparent;color:#fff;',
|
||||
'padding:2px 8px;font:inherit;cursor:pointer;min-width:26px}',
|
||||
'tmf-viewer .tmfv-bar button:hover{background:rgba(255,255,255,.22)}',
|
||||
'tmf-viewer .tmfv-pct{min-width:46px;text-align:center;font-variant-numeric:tabular-nums}',
|
||||
].join('\n');
|
||||
|
||||
// #122: stile iniettato una volta sola nel <head>
|
||||
function injectCss() {
|
||||
if (typeof document === 'undefined' || document.getElementById('tmf-viewer-css')) return;
|
||||
var st = document.createElement('style');
|
||||
st.id = 'tmf-viewer-css';
|
||||
st.textContent = CSS;
|
||||
(document.head || document.documentElement).appendChild(st);
|
||||
}
|
||||
|
||||
function num(v) { var n = parseFloat(v); return n > 0 && isFinite(n) ? n : 0; }
|
||||
|
||||
// #122: dimensioni logiche di un <svg> dal viewBox, altrimenti da width/height
|
||||
function svgDims(svg) {
|
||||
var vb = svg.viewBox && svg.viewBox.baseVal;
|
||||
if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };
|
||||
var a = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
|
||||
if (a.length === 4 && a[2] > 0 && a[3] > 0) return { w: a[2], h: a[3] };
|
||||
return { w: num(svg.getAttribute('width')), h: num(svg.getAttribute('height')) };
|
||||
}
|
||||
|
||||
function defineViewer() {
|
||||
var OBSERVED = ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name',
|
||||
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save'];
|
||||
|
||||
class TmfViewer extends HTMLElement {
|
||||
static get observedAttributes() { return OBSERVED; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._built = false;
|
||||
this._w = 0; this._h = 0; // dimensioni logiche correnti
|
||||
this._nw = 0; this._nh = 0; // dimensioni naturali dell'immagine
|
||||
this._z = 0; this._fit = 0; this._auto = true;
|
||||
this._ov = []; this._lb = [];
|
||||
this._pan = null;
|
||||
this._content = null; // modo contenuto: l'<svg> adottato
|
||||
this._lastEvt = '';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
injectCss();
|
||||
if (this._built) return;
|
||||
this._built = true;
|
||||
// proprieta' impostate prima dell'upgrade dell'elemento: le riporto sugli accessor
|
||||
var self = this;
|
||||
['overlay', 'labels'].forEach(function (k) {
|
||||
if (Object.prototype.hasOwnProperty.call(self, k)) { var v = self[k]; delete self[k]; self[k] = v; }
|
||||
});
|
||||
var kids = Array.prototype.slice.call(this.children);
|
||||
|
||||
var bar = document.createElement('div'); bar.className = 'tmfv-bar'; bar.hidden = true;
|
||||
this._bOut = this._btn('−'); this._bIn = this._btn('+'); this._bFit = this._btn(''); this._bSave = this._btn('');
|
||||
this._pctEl = document.createElement('span'); this._pctEl.className = 'tmfv-pct';
|
||||
bar.appendChild(this._bOut); bar.appendChild(this._bIn); bar.appendChild(this._bFit);
|
||||
bar.appendChild(this._bSave); bar.appendChild(this._pctEl);
|
||||
var vp = document.createElement('div'); vp.className = 'tmfv-vp';
|
||||
var stage = document.createElement('div'); stage.className = 'tmfv-stage';
|
||||
var img = document.createElement('img'); img.className = 'tmfv-img'; img.draggable = false; img.alt = ''; img.hidden = true;
|
||||
var ov = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
ov.setAttribute('class', 'tmfv-ov'); ov.setAttribute('preserveAspectRatio', 'none');
|
||||
var labels = document.createElement('div'); labels.className = 'tmfv-labels';
|
||||
stage.appendChild(img); stage.appendChild(ov); stage.appendChild(labels);
|
||||
vp.appendChild(stage);
|
||||
this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
|
||||
this.appendChild(bar); this.appendChild(vp);
|
||||
|
||||
this._bOut.onclick = function () { self.zoomBy(1 / STEP); };
|
||||
this._bIn.onclick = function () { self.zoomBy(STEP); };
|
||||
this._bFit.onclick = function () { self.fit(); };
|
||||
this._bSave.onclick = function () { self._save(); };
|
||||
img.addEventListener('load', function () { self._onLoad(); });
|
||||
// Ctrl+rotella = zoom intorno al puntatore; senza Ctrl la pagina scorre come sempre
|
||||
vp.addEventListener('wheel', function (ev) { self._wheel(ev); }, { passive: false });
|
||||
vp.addEventListener('pointerdown', function (ev) { self._panDown(ev); });
|
||||
vp.addEventListener('pointermove', function (ev) { self._panMove(ev); });
|
||||
vp.addEventListener('pointerup', function () { self._panEnd(); });
|
||||
vp.addEventListener('pointercancel', function () { self._panEnd(); });
|
||||
// resize del viewport: in stato «adatta» rifa Adatta; altrimenti aggiorna solo il fit
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this._ro = new ResizeObserver(function () { self._onResize(); });
|
||||
this._ro.observe(vp);
|
||||
}
|
||||
// contenuto aggiunto dopo (x-html, template): lo adotto
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
this._mo = new MutationObserver(function () { self._adopt(); });
|
||||
this._mo.observe(this, { childList: true });
|
||||
this._mo2 = new MutationObserver(function () { self._refresh(); });
|
||||
}
|
||||
kids.forEach(function (k) { self._take(k); });
|
||||
this._texts();
|
||||
this._loadSrc();
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// gli osservatori restano: se l'elemento viene ricollegato il viewport e' lo stesso
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldV, newV) {
|
||||
if (!this._built || oldV === newV) return;
|
||||
if (name === 'src') this._loadSrc();
|
||||
else if (name === 'width' || name === 'height') this._refresh();
|
||||
else if (name === 'fit-height') { if (!this.hasAttribute('fit-height')) this._vp.style.height = ''; this._onResize(true); }
|
||||
else this._texts();
|
||||
}
|
||||
|
||||
_btn(txt) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.textContent = txt;
|
||||
return b;
|
||||
}
|
||||
|
||||
// #122: testi e titoli dei pulsanti dagli attributi
|
||||
_texts() {
|
||||
var g = function (el, n, d) { var v = el.getAttribute(n); return v === null || v === '' ? d : v; };
|
||||
this._bFit.textContent = g(this, 'label-fit', 'Adatta');
|
||||
this._bSave.textContent = g(this, 'label-save', 'Salva');
|
||||
this._bIn.title = g(this, 'title-in', 'Ingrandisci');
|
||||
this._bOut.title = g(this, 'title-out', 'Riduci');
|
||||
this._bSave.title = g(this, 'title-save', 'Salva immagine');
|
||||
this._bSave.hidden = this.hasAttribute('no-save');
|
||||
}
|
||||
|
||||
// #122: adotta un figlio <svg> come contenuto (modo contenuto, DXF)
|
||||
_take(el) {
|
||||
if (!el || el === this._bar || el === this._vp || el.parentNode !== this) return;
|
||||
if (String(el.tagName).toLowerCase() !== 'svg') return;
|
||||
if (this._content && this._content !== el && this._content.parentNode) this._content.parentNode.removeChild(this._content);
|
||||
this._content = el;
|
||||
this._stage.insertBefore(el, this._ovEl);
|
||||
if (this._mo2) this._mo2.observe(el, { attributes: true, attributeFilter: ['viewBox', 'width', 'height'] });
|
||||
}
|
||||
|
||||
_adopt() {
|
||||
var kids = Array.prototype.slice.call(this.children), self = this, found = false;
|
||||
kids.forEach(function (k) { if (k !== self._bar && k !== self._vp) { self._take(k); found = true; } });
|
||||
if (found) this._refresh();
|
||||
}
|
||||
|
||||
// #122: (ri)carica l'immagine dall'attributo src
|
||||
_loadSrc() {
|
||||
var src = this.getAttribute('src');
|
||||
if (src) {
|
||||
this._img.hidden = false;
|
||||
this._img.src = src;
|
||||
} else {
|
||||
this._img.removeAttribute('src');
|
||||
this._img.hidden = true;
|
||||
this._nw = 0; this._nh = 0;
|
||||
this._refresh();
|
||||
}
|
||||
}
|
||||
|
||||
_onLoad() {
|
||||
var img = this._img;
|
||||
this._nw = img.naturalWidth; this._nh = img.naturalHeight;
|
||||
this._refresh();
|
||||
this.dispatchEvent(new CustomEvent('viewer-load', { bubbles: true, detail: { width: this._w, height: this._h } }));
|
||||
}
|
||||
|
||||
_hasSrc() { return !!this.getAttribute('src'); }
|
||||
|
||||
// #122: ricalcola le dimensioni logiche; Adatta se cambiano (o se la vista era ad Adatta), zoom mantenuto altrimenti
|
||||
_refresh() {
|
||||
if (!this._built) return;
|
||||
var w = num(this.getAttribute('width')), h = num(this.getAttribute('height'));
|
||||
if (this._hasSrc()) {
|
||||
if (!w) w = this._nw;
|
||||
if (!h) h = this._nh;
|
||||
if (!this._nw || !this._nh) { if (!(w && h)) { w = 0; h = 0; } }
|
||||
} else if (this._content) {
|
||||
var d = svgDims(this._content);
|
||||
if (!w) w = d.w;
|
||||
if (!h) h = d.h;
|
||||
} else { w = 0; h = 0; }
|
||||
if (!(w > 0 && h > 0)) { w = 0; h = 0; }
|
||||
var changed = w !== this._w || h !== this._h;
|
||||
this._w = w; this._h = h;
|
||||
this._bar.hidden = !w;
|
||||
this._img.hidden = !this._hasSrc();
|
||||
if (this._content) this._content.style.display = this._hasSrc() ? 'none' : '';
|
||||
if (!w) { this._z = 0; this._fit = 0; this._auto = true; this._apply(); return; }
|
||||
if (changed || this._auto || !this._z) this.fit(); else this._apply();
|
||||
}
|
||||
|
||||
// #122: area utile del viewport: offset meno i bordi (client* escludono le barre che spariranno dopo l'Adatta)
|
||||
_area() {
|
||||
var vp = this._vp, cw = vp.clientWidth, ch = vp.clientHeight, bx = 0, by = 0;
|
||||
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
|
||||
var cs = getComputedStyle(vp);
|
||||
bx = (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)) || 0;
|
||||
by = (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)) || 0;
|
||||
cw = vp.offsetWidth - bx;
|
||||
ch = vp.offsetHeight - by;
|
||||
}
|
||||
return { cw: cw, ch: ch, by: by };
|
||||
}
|
||||
|
||||
// zoom di Adatta per l'area corrente; 0 se non calcolabile (riquadro nascosto o vuoto)
|
||||
_calcFit() {
|
||||
if (!this._w) return 0;
|
||||
var a = this._area();
|
||||
if (this.hasAttribute('fit-height')) return TmfViewerMath.fitZoom(a.cw, Infinity, this._w, this._h);
|
||||
return TmfViewerMath.fitZoom(a.cw, a.ch, this._w, this._h);
|
||||
}
|
||||
|
||||
// #122: altezza del viewport in fit-height: segue l'immagine adattata alla larghezza
|
||||
_fitHeightBox(fit) {
|
||||
if (!this.hasAttribute('fit-height')) return;
|
||||
this._vp.style.height = fit > 0 ? (this._h * fit + this._area().by) + 'px' : '';
|
||||
}
|
||||
|
||||
fit() {
|
||||
if (!this._built || !this._w) return;
|
||||
var f = this._calcFit();
|
||||
if (!(f > 0)) return; // riquadro nascosto: ci pensa l'osservatore
|
||||
this._fit = f; this._z = f; this._auto = true;
|
||||
this._fitHeightBox(f);
|
||||
this._vp.scrollLeft = 0; this._vp.scrollTop = 0;
|
||||
this._apply();
|
||||
}
|
||||
|
||||
// resize del viewport: Adatta se la vista non e' stata zoomata, altrimenti fit aggiornato e zoom nei limiti
|
||||
_onResize(force) {
|
||||
if (!this._built || !this._w) return;
|
||||
var f = this._calcFit();
|
||||
if (!(f > 0)) return;
|
||||
if (!force && Math.abs(f - this._fit) < 1e-9 && this._z) return;
|
||||
if (this._auto) { this.fit(); return; }
|
||||
this._fit = f;
|
||||
this._z = TmfViewerMath.clampZoom(this._z, f);
|
||||
this._fitHeightBox(f);
|
||||
this._apply();
|
||||
}
|
||||
|
||||
// #122: zoom di un fattore f tenendo fermo il punto (px, py) del viewport (default: il centro)
|
||||
zoomBy(f, px, py) {
|
||||
if (!this._built || !this._w || !this._z) return;
|
||||
var vp = this._vp, z0 = this._z;
|
||||
var z1 = TmfViewerMath.clampZoom(z0 * f, this._fit);
|
||||
if (z1 === z0) return;
|
||||
if (px == null) px = vp.clientWidth / 2;
|
||||
if (py == null) py = vp.clientHeight / 2;
|
||||
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
|
||||
this._z = z1; this._auto = false;
|
||||
this._apply(); // la dimensione dello stage cambia subito, poi si puo' scorrere
|
||||
vp.scrollLeft = s.scrollLeft; vp.scrollTop = s.scrollTop;
|
||||
this._apply(true);
|
||||
}
|
||||
|
||||
get zoom() { return this._z; }
|
||||
|
||||
// #122: applica zoom allo stage, ridisegna overlay ed etichette, aggiorna barra e cursore, avvisa con viewer-zoom
|
||||
_apply(quiet) {
|
||||
if (!this._built) return;
|
||||
var z = this._z;
|
||||
this._stage.style.width = this._w && z ? (this._w * z) + 'px' : '0px';
|
||||
this._stage.style.height = this._w && z ? (this._h * z) + 'px' : '0px';
|
||||
this._pctEl.textContent = TmfViewerMath.pct(z);
|
||||
this._drawOverlay();
|
||||
this._drawLabels();
|
||||
this._cursor();
|
||||
if (quiet) return;
|
||||
var key = z + '|' + this._fit;
|
||||
if (key !== this._lastEvt) {
|
||||
this._lastEvt = key;
|
||||
this.dispatchEvent(new CustomEvent('viewer-zoom', { bubbles: true, detail: { zoom: z, fit: this._fit } }));
|
||||
}
|
||||
}
|
||||
|
||||
_canPan() {
|
||||
var vp = this._vp;
|
||||
return this._w > 0 && (vp.scrollWidth > vp.clientWidth + 1 || vp.scrollHeight > vp.clientHeight + 1);
|
||||
}
|
||||
|
||||
_cursor() {
|
||||
var can = this._canPan();
|
||||
this._vp.style.cursor = can ? (this._pan ? 'grabbing' : 'grab') : 'default';
|
||||
this._vp.style.touchAction = can ? 'none' : 'auto'; // il dito trascina solo se c'e' qualcosa da scorrere
|
||||
}
|
||||
|
||||
_wheel(ev) {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
var r = this._vp.getBoundingClientRect();
|
||||
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop);
|
||||
}
|
||||
|
||||
_panDown(ev) {
|
||||
var vp = this._vp;
|
||||
if (!this._canPan() || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento
|
||||
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||
this._cursor();
|
||||
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||
}
|
||||
|
||||
_panMove(ev) {
|
||||
var p = this._pan;
|
||||
if (!p) return;
|
||||
this._vp.scrollLeft = p.left - (ev.clientX - p.x);
|
||||
this._vp.scrollTop = p.top - (ev.clientY - p.y);
|
||||
}
|
||||
|
||||
_panEnd() { this._pan = null; this._cursor(); }
|
||||
|
||||
// #122: overlay SVG a tratto costante; ridisegnato a ogni zoom perche' le croci dei points restano a dimensione fissa
|
||||
_drawOverlay() {
|
||||
var ov = this._ovEl, VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||
if (!this._w) { ov.innerHTML = ''; return; }
|
||||
ov.setAttribute('viewBox', '0 0 ' + this._w + ' ' + this._h);
|
||||
ov.innerHTML = VO && this._ov && this._ov.length ? VO.svg(this._ov, { zoom: this._z || 1 }) : '';
|
||||
}
|
||||
|
||||
// #122: etichette in px logici -> % dello stage, cosi' seguono lo zoom senza ingrandirsi
|
||||
_drawLabels() {
|
||||
var box = this._lbEl;
|
||||
while (box.firstChild) box.removeChild(box.firstChild);
|
||||
if (!this._w) return;
|
||||
var self = this;
|
||||
(this._lb || []).forEach(function (l) {
|
||||
if (!l || !isFinite(l.x) || !isFinite(l.y)) return;
|
||||
var d = document.createElement('div');
|
||||
d.className = 'tmfv-label' + (l.cls ? ' ' + l.cls : '');
|
||||
if (l.key != null) d.setAttribute('data-key', l.key);
|
||||
d.style.left = (l.x / self._w * 100) + '%';
|
||||
d.style.top = (l.y / self._h * 100) + '%';
|
||||
d.textContent = l.text == null ? '' : String(l.text);
|
||||
box.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
get overlay() { return this._ov; }
|
||||
set overlay(v) { this._ov = Array.isArray(v) ? v : []; if (this._built) this._drawOverlay(); }
|
||||
get labels() { return this._lb; }
|
||||
set labels(v) { this._lb = Array.isArray(v) ? v : []; if (this._built) this._drawLabels(); }
|
||||
|
||||
// #122: Salva = immagine grezza (src o save-src) o, in modo contenuto, l'SVG rasterizzato
|
||||
_save() {
|
||||
var name = this.getAttribute('save-name') || 'immagine';
|
||||
if (this._hasSrc()) {
|
||||
if (typeof tmfSaveImage === 'function') return tmfSaveImage(this.getAttribute('save-src') || this.getAttribute('src'), name);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (!this._content || typeof tmfSvgToPngBlob !== 'function' || typeof tmfSaveBlob !== 'function') return Promise.resolve(false);
|
||||
var self = this;
|
||||
return tmfSvgToPngBlob(this._content, this._w, this._h).then(function (blob) {
|
||||
return tmfSaveBlob(blob, name);
|
||||
}).catch(function (err) {
|
||||
if (typeof console !== 'undefined') console.error('tmf-viewer salva:', err);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
return TmfViewer;
|
||||
}
|
||||
|
||||
// customElements.define una sola volta, anche se lo script e' caricato due volte
|
||||
if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined' &&
|
||||
!(typeof customElements.get === 'function' && customElements.get(TAG))) {
|
||||
customElements.define(TAG, defineViewer());
|
||||
if (typeof document !== 'undefined') injectCss();
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') window.TmfViewerMath = TmfViewerMath;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { TmfViewerMath: TmfViewerMath };
|
||||
})();
|
||||
@@ -8,6 +8,8 @@
|
||||
* Come si usa: <script src=".../save_image.js"></script> poi @click="tmfSaveImage(url, 'task_14')".
|
||||
* Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni
|
||||
* (il modulo si carica anche in node, con require, senza questi globali).
|
||||
* #122: tmfSaveBlob(blob, baseName) salva un blob PNG gia' pronto; tmfSvgToPngBlob(svgEl, w, h) rasterizza un <svg>
|
||||
* (sfondo bianco, lato lungo 1600-4096 px). Entrambi usati da <tmf-viewer> (image_viewer.js).
|
||||
* Idea di conversione da image_to_png.js (#107).
|
||||
*/
|
||||
|
||||
@@ -38,46 +40,119 @@ async function tmfBlobToPng(blob) {
|
||||
return png;
|
||||
}
|
||||
|
||||
// #122: «Salva con nome» (showSaveFilePicker) dove c'e'; null se manca (si ripiega su <a download>). Puo' lanciare AbortError
|
||||
async function tmfPickPng(name) {
|
||||
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
|
||||
return window.showSaveFilePicker({
|
||||
suggestedName: name,
|
||||
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// #122: scrive il blob sul file scelto (handle) oppure, senza handle, lo scarica con <a download>
|
||||
async function tmfWritePng(handle, blob, name) {
|
||||
if (handle) {
|
||||
var w = await handle.createWritable();
|
||||
await w.write(blob);
|
||||
await w.close();
|
||||
return;
|
||||
}
|
||||
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
}
|
||||
|
||||
// #122: gestione comune dell'errore di salvataggio: l'annullo dell'utente non e' un errore
|
||||
function tmfSaveFailed(err, tag) {
|
||||
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
|
||||
if (typeof console !== 'undefined') console.error(tag + ':', err);
|
||||
return false;
|
||||
}
|
||||
|
||||
// #122: salva un blob PNG gia' pronto (es. l'SVG del DXF rasterizzato) come <baseName>_<data>.png; true = salvato
|
||||
async function tmfSaveBlob(blob, baseName) {
|
||||
if (!blob) return false;
|
||||
try {
|
||||
var name = tmfSaveImageName(baseName, new Date());
|
||||
var handle = await tmfPickPng(name);
|
||||
await tmfWritePng(handle, blob, name);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return tmfSaveFailed(err, 'tmfSaveBlob');
|
||||
}
|
||||
}
|
||||
|
||||
// #122: rasterizza un <svg> in PNG (sfondo bianco). w,h = dimensioni logiche (default dal viewBox o dagli attributi);
|
||||
// lato lungo portato fra 1600 e 4096 px. Ritorna una Promise<Blob> o lancia se il browser non riesce a disegnarlo.
|
||||
async function tmfSvgToPngBlob(svgEl, w, h) {
|
||||
var vb = svgEl.getAttribute && svgEl.getAttribute('viewBox');
|
||||
var vbp = vb ? vb.trim().split(/[\s,]+/).map(Number) : [];
|
||||
if (!(w > 0)) w = vbp.length === 4 ? vbp[2] : parseFloat(svgEl.getAttribute('width'));
|
||||
if (!(h > 0)) h = vbp.length === 4 ? vbp[3] : parseFloat(svgEl.getAttribute('height'));
|
||||
if (!(w > 0) || !(h > 0)) throw new Error('dimensioni SVG non valide');
|
||||
var scale = Math.min(4096, Math.max(1600, Math.max(w, h))) / Math.max(w, h);
|
||||
var cw = Math.max(1, Math.round(w * scale));
|
||||
var ch = Math.max(1, Math.round(h * scale));
|
||||
var clone = svgEl.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (!vb) clone.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
clone.setAttribute('width', cw); // dimensione in pixel del PNG: l'SVG isolato non ha il CSS della pagina
|
||||
clone.setAttribute('height', ch);
|
||||
if (clone.style) { clone.style.width = ''; clone.style.height = ''; }
|
||||
var xml = new XMLSerializer().serializeToString(clone);
|
||||
var url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
|
||||
try {
|
||||
var img = new Image();
|
||||
await new Promise(function (resolve, reject) {
|
||||
img.onload = resolve;
|
||||
img.onerror = function () { reject(new Error('SVG non disegnabile')); };
|
||||
img.src = url;
|
||||
});
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
var g = canvas.getContext('2d');
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fillRect(0, 0, cw, ch);
|
||||
g.drawImage(img, 0, 0, cw, ch);
|
||||
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||
if (!png) throw new Error('conversione PNG fallita');
|
||||
return png;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore
|
||||
async function tmfSaveImage(src, baseName) {
|
||||
if (!src) return false; // #119: nessuna immagine mostrata
|
||||
try {
|
||||
var name = tmfSaveImageName(baseName, new Date());
|
||||
// #119: «Salva con nome» per primo, finche' vale il gesto dell'utente (Chrome lo rifiuta dopo ~5 s di fetch e conversione)
|
||||
var handle = null;
|
||||
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
|
||||
handle = await window.showSaveFilePicker({
|
||||
suggestedName: name,
|
||||
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
|
||||
});
|
||||
}
|
||||
var handle = await tmfPickPng(name);
|
||||
var resp = await fetch(src);
|
||||
if (!resp.ok) return false;
|
||||
var blob = await resp.blob();
|
||||
if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob);
|
||||
if (handle) {
|
||||
var w = await handle.createWritable();
|
||||
await w.write(blob);
|
||||
await w.close();
|
||||
return true;
|
||||
}
|
||||
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
await tmfWritePng(handle, blob, name);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
|
||||
if (typeof console !== 'undefined') console.error('tmfSaveImage:', err);
|
||||
return false;
|
||||
return tmfSaveFailed(err, 'tmfSaveImage');
|
||||
}
|
||||
}
|
||||
|
||||
// #119: globale nel browser, export per il test node
|
||||
if (typeof window !== 'undefined') { window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName; }
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName };
|
||||
// #122: esportati anche tmfSaveBlob e tmfSvgToPngBlob (usati da <tmf-viewer>)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName;
|
||||
window.tmfSaveBlob = tmfSaveBlob; window.tmfSvgToPngBlob = tmfSvgToPngBlob;
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob };
|
||||
|
||||
Reference in New Issue
Block a user