b5aa19170e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
294 lines
12 KiB
JavaScript
294 lines
12 KiB
JavaScript
/**
|
|
* image-crop.js — ritaglio di immagine/PDF prima dell'upload (#56).
|
|
*
|
|
* Cosa fa: ImageCrop.open(file) apre una modale con un rettangolo di ritaglio
|
|
* trascinabile e ridimensionabile (mouse e touch, Pointer Events). Per un PDF
|
|
* rasterizza la pagina scelta con pdf.js (>= 2000 px sul lato lungo).
|
|
* Risolve: Blob PNG del ritaglio (a piena risoluzione) | il file originale
|
|
* ("Usa originale") | null ("Annulla").
|
|
* Come si usa: ImageCrop.configure({pdfSrc, workerSrc, labels}) una volta dal
|
|
* template (URL di vendor/pdf e stringhe tradotte), poi `await ImageCrop.open(file)`.
|
|
* Dipendenze: nessuna obbligatoria. pdf.js (gia' vendorizzato) viene caricato
|
|
* al bisogno solo per i PDF; il ritaglio usa un canvas 2D semplice invece di
|
|
* fabric.js perche' serve solo un rettangolo, non un editor di oggetti.
|
|
*/
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
var cfg = {
|
|
pdfSrc: '',
|
|
workerSrc: '',
|
|
labels: {
|
|
title: 'Ritaglia immagine',
|
|
page: 'Pagina',
|
|
hint: 'Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.',
|
|
pdfNote: 'Un PDF ritagliato viene salvato come immagine PNG.',
|
|
confirm: 'Conferma ritaglio',
|
|
original: 'Usa originale',
|
|
cancel: 'Annulla',
|
|
loading: 'Caricamento...',
|
|
error: 'Impossibile aprire il file per il ritaglio.'
|
|
}
|
|
};
|
|
|
|
var MIN_SIDE = 16; // lato minimo del ritaglio, in px immagine
|
|
var HANDLE = 12; // raggio di presa delle maniglie, in px schermo
|
|
var PDF_MIN_LONG = 2000; // lato lungo minimo del raster PDF
|
|
var PDF_MAX_LONG = 6000; // tetto per non esaurire la memoria del canvas
|
|
|
|
function configure(opts) {
|
|
if (!opts) return;
|
|
if (opts.pdfSrc) cfg.pdfSrc = opts.pdfSrc;
|
|
if (opts.workerSrc) cfg.workerSrc = opts.workerSrc;
|
|
if (opts.labels) for (var k in opts.labels) cfg.labels[k] = opts.labels[k];
|
|
}
|
|
|
|
function isPdf(file) {
|
|
return file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
|
|
}
|
|
|
|
function loadPdfJs() {
|
|
if (global.pdfjsLib) return Promise.resolve(global.pdfjsLib);
|
|
if (!cfg.pdfSrc) return Promise.reject(new Error('pdf.js non configurato'));
|
|
return new Promise(function (resolve, reject) {
|
|
var s = document.createElement('script');
|
|
s.src = cfg.pdfSrc;
|
|
s.onload = function () { resolve(global.pdfjsLib); };
|
|
s.onerror = function () { reject(new Error('pdf.js non caricato')); };
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
|
|
function readImage(file) {
|
|
return new Promise(function (resolve, reject) {
|
|
var url = URL.createObjectURL(file);
|
|
var img = new Image();
|
|
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
|
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('immagine illeggibile')); };
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
function renderPdfPage(pdf, n) {
|
|
return pdf.getPage(n).then(function (page) {
|
|
var base = page.getViewport({ scale: 1 });
|
|
var long = Math.max(base.width, base.height);
|
|
var scale = Math.min(PDF_MAX_LONG / long, Math.max(2, PDF_MIN_LONG / long));
|
|
var vp = page.getViewport({ scale: scale });
|
|
var c = document.createElement('canvas');
|
|
c.width = Math.round(vp.width);
|
|
c.height = Math.round(vp.height);
|
|
return page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise
|
|
.then(function () { return c; });
|
|
});
|
|
}
|
|
|
|
function el(tag, cls, text) {
|
|
var e = document.createElement(tag);
|
|
if (cls) e.className = cls;
|
|
if (text) e.textContent = text;
|
|
return e;
|
|
}
|
|
|
|
function open(file) {
|
|
var L = cfg.labels;
|
|
return new Promise(function (resolve) {
|
|
var src = null; // Image o canvas sorgente a piena risoluzione
|
|
var rect = null; // {x,y,w,h} in px immagine
|
|
var pdfDoc = null;
|
|
var closed = false;
|
|
|
|
// ---- DOM (stile Maker: variabili CSS del tema + classi btn gia' presenti) ----
|
|
var overlay = el('div', 'fixed inset-0 z-[100] flex items-center justify-center p-3 bg-black/60');
|
|
overlay.setAttribute('role', 'dialog');
|
|
overlay.setAttribute('aria-modal', 'true');
|
|
var box = el('div', 'rounded-xl shadow-xl flex flex-col w-full max-w-4xl max-h-full');
|
|
box.style.cssText = 'background:var(--bg-primary,#fff);color:var(--text-primary,#111);border:1px solid var(--border-color,#ddd);';
|
|
var head = el('div', 'flex items-center justify-between gap-3 px-4 py-3');
|
|
head.appendChild(el('h3', 'font-semibold text-sm', L.title));
|
|
var pageWrap = el('label', 'text-xs items-center gap-2');
|
|
pageWrap.style.display = 'none';
|
|
pageWrap.appendChild(document.createTextNode(L.page + ' '));
|
|
var pageSel = el('select', 'tmf-input text-xs');
|
|
pageSel.style.width = 'auto';
|
|
pageWrap.appendChild(pageSel);
|
|
head.appendChild(pageWrap);
|
|
var body = el('div', 'px-4 flex-1 min-h-0 overflow-auto flex justify-center');
|
|
var status = el('p', 'text-xs py-6', L.loading);
|
|
body.appendChild(status);
|
|
var canvas = el('canvas');
|
|
canvas.style.cssText = 'max-width:100%;max-height:65vh;touch-action:none;cursor:crosshair;display:none;';
|
|
body.appendChild(canvas);
|
|
var foot = el('div', 'px-4 py-3 flex flex-wrap items-center justify-between gap-2');
|
|
var note = el('p', 'text-xs');
|
|
note.style.color = 'var(--text-muted,#666)';
|
|
note.textContent = L.hint + (isPdf(file) ? ' ' + L.pdfNote : '');
|
|
var btns = el('div', 'flex gap-2');
|
|
var bCancel = el('button', 'btn btn-secondary text-xs', L.cancel);
|
|
var bOrig = el('button', 'btn btn-secondary text-xs', L.original);
|
|
var bOk = el('button', 'btn btn-primary text-xs', L.confirm);
|
|
[bCancel, bOrig, bOk].forEach(function (b) { b.type = 'button'; btns.appendChild(b); });
|
|
bOk.disabled = true;
|
|
foot.appendChild(note);
|
|
foot.appendChild(btns);
|
|
box.appendChild(head); box.appendChild(body); box.appendChild(foot);
|
|
overlay.appendChild(box);
|
|
document.body.appendChild(overlay);
|
|
|
|
function onKey(e) { if (e.key === 'Escape') finish(null); }
|
|
function finish(value) {
|
|
if (closed) return;
|
|
closed = true;
|
|
document.removeEventListener('keydown', onKey);
|
|
overlay.remove();
|
|
resolve(value);
|
|
}
|
|
document.addEventListener('keydown', onKey);
|
|
bCancel.onclick = function () { finish(null); };
|
|
bOrig.onclick = function () { finish(file); };
|
|
bOk.onclick = function () {
|
|
if (!src || !rect) return;
|
|
var out = document.createElement('canvas');
|
|
out.width = Math.round(rect.w);
|
|
out.height = Math.round(rect.h);
|
|
out.getContext('2d').drawImage(src, Math.round(rect.x), Math.round(rect.y),
|
|
out.width, out.height, 0, 0, out.width, out.height);
|
|
out.toBlob(function (blob) { finish(blob || file); }, 'image/png');
|
|
};
|
|
|
|
// ---- disegno ----
|
|
function sw() { return src.width || src.naturalWidth; }
|
|
function sh() { return src.height || src.naturalHeight; }
|
|
function handlePoints(r) {
|
|
var mx = r.x + r.w / 2, my = r.y + r.h / 2, ex = r.x + r.w, ey = r.y + r.h;
|
|
return [
|
|
{ n: 'nw', x: r.x, y: r.y }, { n: 'n', x: mx, y: r.y }, { n: 'ne', x: ex, y: r.y },
|
|
{ n: 'e', x: ex, y: my }, { n: 'se', x: ex, y: ey }, { n: 's', x: mx, y: ey },
|
|
{ n: 'sw', x: r.x, y: ey }, { n: 'w', x: r.x, y: my }
|
|
];
|
|
}
|
|
function draw() {
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
|
|
var r = rect; // il canvas di visualizzazione ha la risoluzione della sorgente
|
|
// velo scuro fuori dal ritaglio (4 fasce)
|
|
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
|
ctx.fillRect(0, 0, canvas.width, r.y);
|
|
ctx.fillRect(0, r.y + r.h, canvas.width, canvas.height - r.y - r.h);
|
|
ctx.fillRect(0, r.y, r.x, r.h);
|
|
ctx.fillRect(r.x + r.w, r.y, canvas.width - r.x - r.w, r.h);
|
|
ctx.strokeStyle = '#3b82f6';
|
|
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
|
ctx.strokeRect(r.x, r.y, r.w, r.h);
|
|
ctx.fillStyle = '#3b82f6';
|
|
var hs = Math.max(8, canvas.width / 80);
|
|
handlePoints(r).forEach(function (p) { ctx.fillRect(p.x - hs / 2, p.y - hs / 2, hs, hs); });
|
|
}
|
|
function setSource(s) {
|
|
src = s;
|
|
canvas.width = sw();
|
|
canvas.height = sh();
|
|
rect = { x: 0, y: 0, w: sw(), h: sh() }; // all'inizio: tutta l'immagine
|
|
status.style.display = 'none';
|
|
canvas.style.display = 'block';
|
|
bOk.disabled = false;
|
|
draw();
|
|
}
|
|
|
|
// ---- interazione ----
|
|
var drag = null;
|
|
function toImg(e) {
|
|
var b = canvas.getBoundingClientRect();
|
|
return { x: (e.clientX - b.left) * sw() / b.width, y: (e.clientY - b.top) * sh() / b.height,
|
|
k: sw() / b.width }; // k = px immagine per px schermo
|
|
}
|
|
function hit(p) {
|
|
var best = null, bd = HANDLE * p.k;
|
|
handlePoints(rect).forEach(function (h) {
|
|
var d = Math.max(Math.abs(h.x - p.x), Math.abs(h.y - p.y));
|
|
if (d <= bd) { bd = d; best = h.n; }
|
|
});
|
|
if (best) return best;
|
|
if (p.x > rect.x && p.x < rect.x + rect.w && p.y > rect.y && p.y < rect.y + rect.h) return 'move';
|
|
return 'new';
|
|
}
|
|
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
|
canvas.addEventListener('pointerdown', function (e) {
|
|
if (!src) return;
|
|
var p = toImg(e);
|
|
var mode = hit(p);
|
|
drag = { mode: mode, sx: p.x, sy: p.y, r0: { x: rect.x, y: rect.y, w: rect.w, h: rect.h } };
|
|
if (mode === 'new') { // nuovo rettangolo: parte dal punto di tocco
|
|
drag.r0 = { x: clamp(p.x, 0, sw()), y: clamp(p.y, 0, sh()), w: 0, h: 0 };
|
|
}
|
|
canvas.setPointerCapture(e.pointerId);
|
|
e.preventDefault();
|
|
});
|
|
canvas.addEventListener('pointermove', function (e) {
|
|
if (!src) return;
|
|
var p = toImg(e);
|
|
if (!drag) {
|
|
var m = hit(p);
|
|
canvas.style.cursor = m === 'move' ? 'move' : m === 'new' ? 'crosshair'
|
|
: (m.length === 2 ? (m === 'nw' || m === 'se' ? 'nwse-resize' : 'nesw-resize')
|
|
: (m === 'n' || m === 's' ? 'ns-resize' : 'ew-resize'));
|
|
return;
|
|
}
|
|
var dx = p.x - drag.sx, dy = p.y - drag.sy, r0 = drag.r0, W = sw(), H = sh();
|
|
if (drag.mode === 'move') {
|
|
rect.x = clamp(r0.x + dx, 0, W - r0.w);
|
|
rect.y = clamp(r0.y + dy, 0, H - r0.h);
|
|
} else {
|
|
var x1 = r0.x, y1 = r0.y, x2 = r0.x + r0.w, y2 = r0.y + r0.h, m2 = drag.mode;
|
|
if (m2 === 'new') { x2 = clamp(p.x, 0, W); y2 = clamp(p.y, 0, H); }
|
|
else {
|
|
if (m2.indexOf('w') >= 0) x1 = clamp(r0.x + dx, 0, x2 - MIN_SIDE);
|
|
if (m2.indexOf('e') >= 0) x2 = clamp(x2 + dx, x1 + MIN_SIDE, W);
|
|
if (m2.indexOf('n') >= 0) y1 = clamp(r0.y + dy, 0, y2 - MIN_SIDE);
|
|
if (m2.indexOf('s') >= 0) y2 = clamp(y2 + dy, y1 + MIN_SIDE, H);
|
|
}
|
|
rect = { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
|
|
}
|
|
bOk.disabled = rect.w < MIN_SIDE || rect.h < MIN_SIDE; // ritaglio troppo piccolo: non confermabile
|
|
draw();
|
|
e.preventDefault();
|
|
});
|
|
function up() { drag = null; }
|
|
canvas.addEventListener('pointerup', up);
|
|
canvas.addEventListener('pointercancel', up);
|
|
|
|
// ---- caricamento sorgente ----
|
|
function fail(err) {
|
|
console.error('ImageCrop:', err);
|
|
status.style.display = '';
|
|
status.textContent = L.error;
|
|
canvas.style.display = 'none';
|
|
bOk.disabled = true;
|
|
}
|
|
if (isPdf(file)) {
|
|
loadPdfJs().then(function (lib) {
|
|
if (cfg.workerSrc) lib.GlobalWorkerOptions.workerSrc = cfg.workerSrc;
|
|
return file.arrayBuffer().then(function (buf) { return lib.getDocument({ data: buf }).promise; });
|
|
}).then(function (pdf) {
|
|
pdfDoc = pdf;
|
|
if (pdf.numPages > 1) {
|
|
for (var i = 1; i <= pdf.numPages; i++) {
|
|
var o = el('option', '', String(i)); o.value = String(i); pageSel.appendChild(o);
|
|
}
|
|
pageWrap.style.display = 'flex';
|
|
}
|
|
return renderPdfPage(pdf, 1);
|
|
}).then(setSource).catch(fail);
|
|
pageSel.onchange = function () {
|
|
bOk.disabled = true;
|
|
renderPdfPage(pdfDoc, parseInt(pageSel.value, 10)).then(setSource).catch(fail);
|
|
};
|
|
} else {
|
|
readImage(file).then(setSource).catch(fail);
|
|
}
|
|
});
|
|
}
|
|
|
|
global.ImageCrop = { open: open, configure: configure };
|
|
})(window);
|