feat: potenziata visualizzazione storico consuntivazione. style: disabilitata apertura massiva. feat: migliorata modifica pdf e scaricamento di tutti gli allegati tramite shortcut.

This commit is contained in:
Gabriele Cimaschi committed 2026-09-10 01:02:25 +02:00
1 parent b1754c3a67
commit c7b18ac959
10 files changed
+1545 -191

No files matched your search

+575 -63
View File
@@ -246,6 +246,7 @@ const TicketDetailView = {
<button id="pdf-tool-text" class="btn btn-ghost btn-sm" title="Aggiungi Testo" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🔤 Testo</button>
<button id="pdf-tool-highlight" class="btn btn-ghost btn-sm" title="Evidenziatore" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🖍️ Evidenzia</button>
<button id="pdf-tool-shape" class="btn btn-ghost btn-sm" title="Inserisci Forme" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">📐 Forme</button>
<button id="pdf-tool-signature" class="btn btn-ghost btn-sm" title="Apponi Timbro / Firma Utente" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">✍️ Firma</button>
<!-- Main Color Picker -->
<input type="color" id="pdf-color-picker" value="#ef4444" title="Colore primario" style="width:26px; height:26px; padding:0; border:none; border-radius:50%; cursor:pointer; background:none;" />
@@ -255,6 +256,8 @@ const TicketDetailView = {
<select id="pdf-shape-type" style="height: 26px; background: #0f172a; color: #fff; border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; font-size: 0.76rem; padding: 0 4px; cursor: pointer;">
<option value="rectangle">🟦 Rettangolo</option>
<option value="circle">⚪ Cerchio</option>
<option value="line">📏 Linea</option>
<option value="arrow">🏹 Freccia</option>
</select>
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;" title="Colore Interno">
Riemp.
@@ -339,19 +342,45 @@ const TicketDetailView = {
overlay.focus();
};
this.pdfHasBeenSaved = false;
const checkPdfHasModifications = () => {
if (this.pdfHasBeenSaved) return false;
const canvases = document.querySelectorAll('.pdf-annotation-canvas');
for (const c of canvases) {
if (c.pageObjects && c.pageObjects.length > 0) return true;
const ctx = c.getContext('2d');
const imgData = ctx.getImageData(0, 0, c.width, c.height);
if (imgData.data.some(channel => channel !== 0)) return true;
}
return false;
};
const confirmDiscardChanges = () => {
if (checkPdfHasModifications()) {
return confirm('Attenzione: Sono presenti modifiche non salvate al PDF. Vuoi davvero uscire o cambiare allegato senza salvare?');
}
return true;
};
const showPrev = () => {
if (itemsList.length <= 1) return;
if (!confirmDiscardChanges()) return;
this.pdfHasBeenSaved = false;
currentIndex = (currentIndex - 1 + itemsList.length) % itemsList.length;
updateDisplay();
};
const showNext = () => {
if (itemsList.length <= 1) return;
if (!confirmDiscardChanges()) return;
this.pdfHasBeenSaved = false;
currentIndex = (currentIndex + 1) % itemsList.length;
updateDisplay();
};
const closeLightbox = () => {
if (!confirmDiscardChanges()) return;
window.currentPdfDoc = null;
document.removeEventListener('keydown', handleKeydown, true);
window.removeEventListener('keydown', handleKeydown, true);
@@ -449,6 +478,7 @@ const TicketDetailView = {
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
this.pdfHasBeenSaved = true;
Toast.success(`PDF modificato scaricato: ${outName}`);
} catch (saveErr) {
console.error('Error saving edited PDF', saveErr);
@@ -462,12 +492,32 @@ const TicketDetailView = {
};
const handleKeydown = (e) => {
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
// If user is editing text in an input, textarea or contenteditable element, allow standard cursor navigation
const activeTag = document.activeElement ? document.activeElement.tagName.toUpperCase() : '';
const isInputOrTextarea = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable);
if (isInputOrTextarea) {
return;
}
if (isCtrlOrCmd && e.key === 'Enter') {
const currentItem = itemsList[currentIndex];
if (currentItem && App.isShortcutTriggered(e, 'pdf_save_edited')) {
e.preventDefault();
e.stopPropagation();
savePdfDocument();
if (currentItem.type === 'pdf') {
savePdfDocument();
} else {
// Non-PDF attachment (e.g. image): trigger download/open link
const downloadBtn = overlay.querySelector('#lightbox-download-btn');
if (downloadBtn) {
downloadBtn.click();
} else if (currentItem.src) {
const a = document.createElement('a');
a.href = currentItem.src;
a.download = currentItem.filename || 'allegato';
a.target = '_blank';
a.click();
}
}
return;
}
@@ -542,13 +592,25 @@ const TicketDetailView = {
if (pageNumEl) pageNumEl.textContent = `Pagina 1 / ${totalPages}`;
let currentTool = 'draw'; // 'draw' | 'text' | 'highlight'
let currentTool = 'draw'; // 'draw' | 'text' | 'highlight' | 'shape' | 'signature'
let currentColor = '#ef4444';
let activeSignature = null;
// Pre-fetch signatures to select default signature
try {
App.api('/api/config/user-signatures').then(sigs => {
if (sigs && sigs.length > 0) {
activeSignature = sigs.find(s => s.is_default) || sigs[0];
}
}).catch(() => {});
} catch (_) {}
const toolSelectBtn = document.getElementById('pdf-tool-select');
const toolDrawBtn = document.getElementById('pdf-tool-draw');
const toolTextBtn = document.getElementById('pdf-tool-text');
const toolHighlightBtn = document.getElementById('pdf-tool-highlight');
const toolShapeBtn = document.getElementById('pdf-tool-shape');
const toolSigBtn = document.getElementById('pdf-tool-signature');
const shapeControls = document.getElementById('pdf-shape-controls');
const colorPicker = document.getElementById('pdf-color-picker');
const toolClearBtn = document.getElementById('pdf-tool-clear');
@@ -556,7 +618,7 @@ const TicketDetailView = {
const setActiveTool = (tool, activeBtn) => {
currentTool = tool;
const allToolBtns = [toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, document.getElementById('pdf-tool-select')];
const allToolBtns = [toolSelectBtn, toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, toolSigBtn];
allToolBtns.forEach(b => {
if (b) {
b.style.background = 'transparent';
@@ -570,11 +632,127 @@ const TicketDetailView = {
if (shapeControls) {
shapeControls.style.display = tool === 'shape' ? 'flex' : 'none';
}
// Adjust pointer-events for text selection layer vs annotation canvas
for (let num = 1; num <= totalPages; num++) {
const dCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`);
const tLayer = document.getElementById(`pdf-text-layer-page-${num}`);
if (dCanvas) {
dCanvas.style.pointerEvents = tool === 'select' ? 'none' : 'auto';
}
if (tLayer) {
tLayer.style.pointerEvents = tool === 'select' ? 'auto' : 'none';
}
}
};
if (toolSelectBtn) toolSelectBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('select', toolSelectBtn); };
if (toolDrawBtn) toolDrawBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('draw', toolDrawBtn); };
if (toolTextBtn) toolTextBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('text', toolTextBtn); };
if (toolHighlightBtn) toolHighlightBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('highlight', toolHighlightBtn); };
if (toolSigBtn) {
toolSigBtn.onclick = async (e) => {
e.stopPropagation();
setActiveTool('signature', toolSigBtn);
let sigPopup = document.getElementById('pdf-sig-picker-popup');
if (sigPopup) {
sigPopup.remove();
return;
}
let allSignatures = [];
try {
allSignatures = await App.api('/api/config/user-signatures');
} catch (_) {}
if (!activeSignature && allSignatures.length > 0) {
activeSignature = allSignatures.find(s => s.is_default) || allSignatures[0];
}
const btnRect = toolSigBtn.getBoundingClientRect();
sigPopup = document.createElement('div');
sigPopup.id = 'pdf-sig-picker-popup';
sigPopup.style.cssText = `
position: fixed;
z-index: 20000;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
color: #ffffff;
font-size: 0.78rem;
min-width: 240px;
max-height: 290px;
overflow-y: auto;
user-select: none;
`;
sigPopup.innerHTML = `
<div style="font-weight: 700; margin-bottom: 6px; color: var(--accent-primary); border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between;">
<span>✍️ Seleziona Firma da Apporre</span>
<span style="cursor:pointer; opacity:0.7;" id="sig-popup-close">✕</span>
</div>
${allSignatures.length > 0 ? `
<div style="font-size:0.72rem; color:#94a3b8; margin-bottom:6px;">Clicca per scegliere la firma da timbrare:</div>
<div style="display:flex; flex-direction:column; gap:4px;">
${allSignatures.map(s => `
<button class="sig-picker-item ${activeSignature && activeSignature.id === s.id ? 'selected' : ''}" data-id="${s.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:${activeSignature && activeSignature.id === s.id ? 'var(--accent-primary)' : 'rgba(255,255,255,0.08)'}; border:1px solid rgba(255,255,255,0.15); color:#fff; padding:6px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<img src="${s.image_data}" style="width:32px; height:22px; object-fit:contain; background:#fff; border-radius:2px; padding:1px; flex-shrink:0;" />
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(s.name)}</span>
${s.is_default ? '<span style="font-size:0.68rem; color:#fde047; font-weight:bold;">⭐ Predefinita</span>' : ''}
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic; padding:8px 0;">
Nessuna firma salvata nel database.<br>Puoi caricarne su <strong>Configurazioni Utente</strong>.
</div>
`}
`;
document.body.appendChild(sigPopup);
const sRect = sigPopup.getBoundingClientRect();
const sWinW = window.innerWidth;
const sWinH = window.innerHeight;
let sLeft = btnRect.left;
let sTop = btnRect.bottom + 6;
if (sLeft + sRect.width > sWinW - 12) sLeft = sWinW - sRect.width - 12;
if (sLeft < 12) sLeft = 12;
if (sTop + sRect.height > sWinH - 12) {
sTop = btnRect.top - sRect.height - 6;
if (sTop < 12) sTop = sWinH - sRect.height - 12;
}
sigPopup.style.left = `${sLeft}px`;
sigPopup.style.top = `${sTop}px`;
const closeSigPopup = () => { if (sigPopup) sigPopup.remove(); };
sigPopup.querySelector('#sig-popup-close').onclick = closeSigPopup;
sigPopup.querySelectorAll('.sig-picker-item').forEach(btn => {
btn.onclick = (eBtn) => {
eBtn.stopPropagation();
const found = allSignatures.find(s => s.id == btn.dataset.id);
if (found) {
activeSignature = found;
Toast.success(`Firma "${found.name}" selezionata! Clicca sul PDF per apporla.`);
}
closeSigPopup();
};
});
const closeOnOutside = (evt) => {
if (!sigPopup.contains(evt.target) && evt.target !== toolSigBtn) {
closeSigPopup();
document.removeEventListener('mousedown', closeOnOutside, true);
}
};
setTimeout(() => document.addEventListener('mousedown', closeOnOutside, true), 50);
};
}
if (toolShapeBtn) {
toolShapeBtn.onclick = async (e) => {
e.stopPropagation();
@@ -620,9 +798,11 @@ const TicketDetailView = {
<div style="margin-bottom: 8px;">
<label style="font-size:0.72rem; color:#94a3b8; display:block; margin-bottom:4px;">Forme Base</label>
<div style="display:flex; gap:4px;">
<button class="base-shape-btn" data-type="rectangle" style="flex:1; padding:6px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.76rem;">🟦 Rettangolo</button>
<button class="base-shape-btn" data-type="circle" style="flex:1; padding:6px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.76rem;">⚪ Cerchio</button>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:4px;">
<button class="base-shape-btn" data-type="rectangle" style="padding:5px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.75rem;">🟦 Rettangolo</button>
<button class="base-shape-btn" data-type="circle" style="padding:5px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.75rem;">⚪ Cerchio</button>
<button class="base-shape-btn" data-type="line" style="padding:5px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.75rem;">📏 Linea</button>
<button class="base-shape-btn" data-type="arrow" style="padding:5px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.75rem;">🏹 Freccia</button>
</div>
</div>
@@ -646,6 +826,28 @@ const TicketDetailView = {
document.body.appendChild(shapePopup);
const sRect = shapePopup.getBoundingClientRect();
const sWinW = window.innerWidth;
const sWinH = window.innerHeight;
let sLeft = btnRect.left;
let sTop = btnRect.bottom + 6;
if (sLeft + sRect.width > sWinW - 12) {
sLeft = sWinW - sRect.width - 12;
}
if (sLeft < 12) {
sLeft = 12;
}
if (sTop + sRect.height > sWinH - 12) {
sTop = btnRect.top - sRect.height - 6;
if (sTop < 12) sTop = sWinH - sRect.height - 12;
}
shapePopup.style.left = `${sLeft}px`;
shapePopup.style.top = `${sTop}px`;
const closePopup = () => { if (shapePopup) shapePopup.remove(); };
shapePopup.querySelector('#shape-popup-close').onclick = closePopup;
@@ -690,17 +892,83 @@ const TicketDetailView = {
}
if (colorPicker) colorPicker.onchange = (e) => { currentColor = e.target.value; };
// Create page containers, base canvases, and annotation overlay canvases
const shapeBorderWidthEl = document.getElementById('pdf-shape-border-width');
if (shapeBorderWidthEl) {
shapeBorderWidthEl.oninput = (e) => {
const val = parseInt(e.target.value, 10) || 1;
if (selectedObject && selectedObject.type === 'shape') {
selectedObject.borderWidth = val;
redrawPageObjects();
}
};
}
const shapeBorderColorEl = document.getElementById('pdf-shape-border-color');
if (shapeBorderColorEl) {
shapeBorderColorEl.oninput = (e) => {
if (selectedObject && selectedObject.type === 'shape') {
selectedObject.borderColor = e.target.value;
redrawPageObjects();
}
};
}
const shapeFillColorEl = document.getElementById('pdf-shape-fill-color');
if (shapeFillColorEl) {
shapeFillColorEl.oninput = (e) => {
if (selectedObject && selectedObject.type === 'shape') {
selectedObject.fillColor = e.target.value;
redrawPageObjects();
}
};
}
// Helper for manual text layer rendering fallback
const renderManualTextLayer = (textContent, textLayerDiv, viewport) => {
textLayerDiv.innerHTML = '';
if (!textContent || !textContent.items) return;
textContent.items.forEach(item => {
if (!item.str) return;
const tx = window.pdfjsLib.Util.transform(viewport.transform, item.transform);
const fontHeight = Math.hypot(tx[2], tx[3]);
const span = document.createElement('span');
span.textContent = item.str;
span.style.cssText = `
position: absolute;
left: ${tx[4]}px;
top: ${tx[5] - fontHeight}px;
font-size: ${fontHeight}px;
font-family: sans-serif;
color: transparent;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
line-height: 1;
`;
if (tx[0] !== 1 || tx[1] !== 0) {
const angle = Math.atan2(tx[1], tx[0]);
if (angle !== 0) span.style.transform = `rotate(${angle}rad)`;
}
textLayerDiv.appendChild(span);
});
};
// Create page containers, base canvases, text layers, and annotation overlay canvases
for (let num = 1; num <= totalPages; num++) {
const pageContainer = document.createElement('div');
pageContainer.className = 'pdf-page-container';
pageContainer.dataset.pageNum = num;
pageContainer.style.cssText = 'position: relative; margin-bottom: 16px; display: flex; flex-direction: column; align-items: center; user-select: none;';
pageContainer.style.cssText = 'position: relative; margin-bottom: 16px; display: flex; flex-direction: column; align-items: center; user-select: text;';
const baseCanvas = document.createElement('canvas');
baseCanvas.id = `pdf-canvas-page-${num}`;
baseCanvas.style.cssText = 'box-shadow: 0 4px 20px rgba(0,0,0,0.4); border-radius: 4px; background: #ffffff; display: block;';
const textLayerDiv = document.createElement('div');
textLayerDiv.id = `pdf-text-layer-page-${num}`;
textLayerDiv.className = 'textLayer';
textLayerDiv.style.cssText = 'position: absolute; top: 0; left: 50%; transform: translateX(-50%); border-radius: 4px; z-index: 5; pointer-events: none; user-select: text; -webkit-user-select: text;';
const drawCanvas = document.createElement('canvas');
drawCanvas.id = `pdf-draw-canvas-page-${num}`;
drawCanvas.className = 'pdf-annotation-canvas';
@@ -708,6 +976,7 @@ const TicketDetailView = {
drawCanvas.style.cssText = 'position: absolute; top: 0; left: 50%; transform: translateX(-50%); border-radius: 4px; cursor: crosshair; z-index: 10; touch-action: none;';
pageContainer.appendChild(baseCanvas);
pageContainer.appendChild(textLayerDiv);
pageContainer.appendChild(drawCanvas);
wrapper.appendChild(pageContainer);
}
@@ -720,12 +989,19 @@ const TicketDetailView = {
const page = await pdfDoc.getPage(num);
const viewport = page.getViewport({ scale: 1.25 });
const baseCanvas = document.getElementById(`pdf-canvas-page-${num}`);
const textLayerDiv = document.getElementById(`pdf-text-layer-page-${num}`);
const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`);
if (!baseCanvas || !drawCanvas) continue;
baseCanvas.height = viewport.height;
baseCanvas.width = viewport.width;
if (textLayerDiv) {
textLayerDiv.style.width = `${viewport.width}px`;
textLayerDiv.style.height = `${viewport.height}px`;
textLayerDiv.style.setProperty('--scale-factor', viewport.scale);
}
drawCanvas.height = viewport.height;
drawCanvas.width = viewport.width;
@@ -737,6 +1013,36 @@ const TicketDetailView = {
await page.render(renderContext).promise;
if (textLayerDiv) {
try {
const textContent = await page.getTextContent();
if (window.pdfjsLib.renderTextLayer) {
const task = window.pdfjsLib.renderTextLayer({
textContentSource: textContent,
container: textLayerDiv,
viewport: viewport,
textDivs: []
});
if (task && task.promise) await task.promise;
} else if (window.pdfjsLib.TextLayer) {
const textLayer = new window.pdfjsLib.TextLayer({
textContentSource: textContent,
container: textLayerDiv,
viewport: viewport
});
await textLayer.render();
} else {
renderManualTextLayer(textContent, textLayerDiv, viewport);
}
} catch (textErr) {
console.warn('PDF text layer render note:', textErr);
try {
const textContent = await page.getTextContent();
renderManualTextLayer(textContent, textLayerDiv, viewport);
} catch (_) {}
}
}
// Setup Object-Based Interactive Annotation System for each PDF page
const drawCtx = drawCanvas.getContext('2d');
@@ -800,38 +1106,102 @@ const TicketDetailView = {
const cx = obj.x + rx;
const cy = obj.y + ry;
drawCtx.ellipse(cx, cy, Math.abs(rx), Math.abs(ry), 0, 0, Math.PI * 2);
if (obj.fillColor && obj.fillColor !== 'transparent') {
drawCtx.fillStyle = obj.fillColor;
drawCtx.fill();
}
if (obj.borderWidth > 0 && obj.borderColor) {
drawCtx.lineWidth = obj.borderWidth;
drawCtx.strokeStyle = obj.borderColor;
drawCtx.stroke();
}
} else if (obj.shapeType === 'line' || obj.shapeType === 'arrow') {
const startX = obj.x;
const startY = obj.y;
const endX = obj.x + obj.width;
const endY = obj.y + obj.height;
const bw = obj.borderWidth || 3;
const strokeColor = obj.borderColor || obj.fillColor || '#1e3a8a';
if (obj.shapeType === 'arrow') {
const angle = Math.atan2(endY - startY, endX - startX);
const headLength = Math.max(14, bw * 3.5);
const shorten = headLength * 0.6; // Shorten main line stem so round lineCap doesn't stick out past arrow tip
const stemEndX = endX - shorten * Math.cos(angle);
const stemEndY = endY - shorten * Math.sin(angle);
drawCtx.moveTo(startX, startY);
drawCtx.lineTo(stemEndX, stemEndY);
drawCtx.lineWidth = bw;
drawCtx.strokeStyle = strokeColor;
drawCtx.lineCap = 'round';
drawCtx.stroke();
drawCtx.beginPath();
drawCtx.moveTo(endX, endY);
drawCtx.lineTo(
endX - headLength * Math.cos(angle - Math.PI / 6),
endY - headLength * Math.sin(angle - Math.PI / 6)
);
drawCtx.lineTo(
endX - headLength * Math.cos(angle + Math.PI / 6),
endY - headLength * Math.sin(angle + Math.PI / 6)
);
drawCtx.closePath();
drawCtx.fillStyle = strokeColor;
drawCtx.fill();
} else {
drawCtx.moveTo(startX, startY);
drawCtx.lineTo(endX, endY);
drawCtx.lineWidth = bw;
drawCtx.strokeStyle = strokeColor;
drawCtx.lineCap = 'round';
drawCtx.stroke();
}
} else {
drawCtx.rect(obj.x, obj.y, obj.width, obj.height);
}
if (obj.fillColor && obj.fillColor !== 'transparent') {
drawCtx.fillStyle = obj.fillColor;
drawCtx.fill();
}
if (obj.borderWidth > 0 && obj.borderColor) {
drawCtx.lineWidth = obj.borderWidth;
drawCtx.strokeStyle = obj.borderColor;
drawCtx.stroke();
if (obj.fillColor && obj.fillColor !== 'transparent') {
drawCtx.fillStyle = obj.fillColor;
drawCtx.fill();
}
if (obj.borderWidth > 0 && obj.borderColor) {
drawCtx.lineWidth = obj.borderWidth;
drawCtx.strokeStyle = obj.borderColor;
drawCtx.stroke();
}
}
}
drawCtx.restore();
// Draw selection box & resize handles if selected
// Draw selection box & resize/endpoint handles if selected
if (selectedObject === obj) {
drawCtx.save();
drawCtx.strokeStyle = '#3b82f6';
drawCtx.lineWidth = 1.5;
drawCtx.setLineDash([4, 4]);
drawCtx.strokeRect(obj.x - 4, obj.y - 4, obj.width + 8, obj.height + 8);
drawCtx.setLineDash([]);
// Handles at corners and midpoints
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
// For line and arrow, draw guide line between origin and endpoint
drawCtx.setLineDash([3, 3]);
drawCtx.beginPath();
drawCtx.moveTo(obj.x, obj.y);
drawCtx.lineTo(obj.x + obj.width, obj.y + obj.height);
drawCtx.stroke();
drawCtx.setLineDash([]);
} else {
drawCtx.setLineDash([4, 4]);
drawCtx.strokeRect(obj.x - 4, obj.y - 4, obj.width + 8, obj.height + 8);
drawCtx.setLineDash([]);
}
// Handles at corners/endpoints
const handles = getObjectHandles(obj);
handles.forEach(h => {
drawCtx.fillStyle = '#ffffff';
drawCtx.strokeStyle = '#2563eb';
drawCtx.lineWidth = 1.5;
drawCtx.beginPath();
drawCtx.arc(h.x, h.y, 4.5, 0, Math.PI * 2);
drawCtx.arc(h.x, h.y, 5, 0, Math.PI * 2);
drawCtx.fill();
drawCtx.stroke();
});
@@ -845,6 +1215,16 @@ const TicketDetailView = {
const y = obj.y;
const w = obj.width;
const h = obj.height;
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
// Handle 0: Start / Origin point (P1), Handle 1: Midpoint, Handle 2: End / Target point (P2)
return [
{ x: x, y: y }, // 0: Punto di Origine
{ x: x + w / 2, y: y + h / 2 }, // 1: Punto Centrale
{ x: x + w, y: y + h } // 2: Punto di Arrivo
];
}
return [
{ x: x, y: y }, // 0: Top-Left
{ x: x + w / 2, y: y }, // 1: Top-Center
@@ -1005,6 +1385,31 @@ const TicketDetailView = {
document.body.appendChild(menuOverlay);
// Clamp positioning to prevent overflowing window boundaries
const menuRect = menuOverlay.getBoundingClientRect();
const winW = window.innerWidth;
const winH = window.innerHeight;
let finalLeft = e.clientX;
let finalTop = e.clientY;
if (finalLeft + menuRect.width > winW - 12) {
finalLeft = winW - menuRect.width - 12;
}
if (finalLeft < 12) {
finalLeft = 12;
}
if (finalTop + menuRect.height > winH - 12) {
finalTop = winH - menuRect.height - 12;
}
if (finalTop < 12) {
finalTop = 12;
}
menuOverlay.style.left = `${finalLeft}px`;
menuOverlay.style.top = `${finalTop}px`;
// Menu Event Listeners
const closeCtx = () => { if (menuOverlay) menuOverlay.remove(); };
menuOverlay.querySelector('#ctx-close').onclick = closeCtx;
@@ -1043,7 +1448,20 @@ const TicketDetailView = {
if (sizeInput) {
sizeInput.oninput = (eVal) => {
eVal.stopPropagation();
currentBrushSize = parseInt(eVal.target.value, 10);
const val = parseInt(eVal.target.value, 10);
currentBrushSize = val;
// Update shape border width input in toolbar if shape tool is active
const shapeBorderWidthEl = document.getElementById('pdf-shape-border-width');
if (shapeBorderWidthEl) {
shapeBorderWidthEl.value = val;
}
// If a shape object is currently selected, update its borderWidth live
if (selectedObject && selectedObject.type === 'shape') {
selectedObject.borderWidth = val;
redrawPageObjects();
}
};
}
@@ -1174,6 +1592,33 @@ const TicketDetailView = {
return;
}
if (currentTool === 'signature') {
if (activeSignature) {
const img = new Image();
img.onload = () => {
const targetWidth = Math.min(220, img.width || 180);
const targetHeight = (img.height / img.width) * targetWidth || 80;
const newObj = {
id: Date.now(),
type: 'image',
img: img,
x: x - targetWidth / 2,
y: y - targetHeight / 2,
width: targetWidth,
height: targetHeight
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Firma "${activeSignature.name}" apposta sul PDF!`);
};
img.src = activeSignature.image_data;
} else {
Toast.warning('Nessuna firma selezionata. Clicca sul pulsante ✍️ Firma per sceglierne una.');
}
return;
}
const openInlineTextEditor = (clientX, clientY, initialX, initialY, existingTextObj = null) => {
let inlineInput = document.getElementById('pdf-inline-text-input');
if (inlineInput) inlineInput.remove();
@@ -1297,6 +1742,28 @@ const TicketDetailView = {
const borderColor = borderColorEl ? borderColorEl.value : '#1e3a8a';
const borderWidth = borderWidthEl ? parseInt(borderWidthEl.value, 10) : 3;
if (shapeType === 'line' || shapeType === 'arrow') {
// Interactive click-and-drag line / arrow creation
const newShapeObj = {
id: Date.now(),
type: 'shape',
shapeType: shapeType,
x: x,
y: y,
width: 0,
height: 0,
fillColor: fillColor,
borderColor: borderColor,
borderWidth: borderWidth
};
pageObjects.push(newShapeObj);
selectedObject = newShapeObj;
isResizingObj = true;
resizeHandleIndex = 2; // Dragging target/endpoint handle
redrawPageObjects();
return;
}
const defaultW = 120;
const defaultH = 80;
@@ -1356,7 +1823,20 @@ const TicketDetailView = {
const oldW = obj.width || 1;
const oldH = obj.height || 1;
if (resizeHandleIndex === 0) { // Top-Left
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
// Line / Arrow endpoint dragging
if (resizeHandleIndex === 0) { // Origin (P1: x, y)
const endX = obj.x + obj.width;
const endY = obj.y + obj.height;
obj.x = x;
obj.y = y;
obj.width = endX - x;
obj.height = endY - y;
} else if (resizeHandleIndex === 2) { // Target / Endpoint (P2: x+width, y+height)
obj.width = x - obj.x;
obj.height = y - obj.y;
}
} else if (resizeHandleIndex === 0) { // Top-Left
const oldRight = obj.x + obj.width;
const oldBottom = obj.y + obj.height;
obj.x = Math.min(x, oldRight - 10);
@@ -1440,6 +1920,34 @@ const TicketDetailView = {
}
const hoverObj = hitTestObject(x, y);
drawCanvas.style.cursor = hoverObj ? 'move' : (currentTool === 'select' ? 'default' : 'crosshair');
// Live preview ghost for active signature under cursor
if (currentTool === 'signature' && activeSignature && !isDrawingStroke && !isDraggingObj && !isResizingObj) {
redrawPageObjects();
if (!activeSignature._imgCache || activeSignature._imgCache.src !== activeSignature.image_data) {
const cachedImg = new Image();
cachedImg.src = activeSignature.image_data;
activeSignature._imgCache = cachedImg;
}
const img = activeSignature._imgCache;
if (img && img.complete && img.naturalWidth !== 0) {
const targetW = Math.min(220, img.width || 180);
const targetH = (img.height / img.width) * targetW || 80;
const ghostX = x - targetW / 2;
const ghostY = y - targetH / 2;
drawCtx.save();
drawCtx.globalAlpha = 0.6;
drawCtx.drawImage(img, ghostX, ghostY, targetW, targetH);
drawCtx.strokeStyle = '#3b82f6';
drawCtx.lineWidth = 1.5;
drawCtx.setLineDash([4, 4]);
drawCtx.strokeRect(ghostX - 2, ghostY - 2, targetW + 4, targetH + 4);
drawCtx.restore();
}
return;
}
if (!isDrawingStroke) return;
e.preventDefault();
e.stopPropagation();
@@ -1485,7 +1993,10 @@ const TicketDetailView = {
};
drawCanvas.onmouseup = stopDrawing;
drawCanvas.onmouseleave = stopDrawing;
drawCanvas.onmouseleave = (e) => {
stopDrawing(e);
redrawPageObjects();
};
} catch (pErr) {
console.warn(`Failed rendering PDF page ${num}`, pErr);
@@ -1973,13 +2484,46 @@ const TicketDetailView = {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
<!-- Pulsante Info (i) a destra del cestino con popover al passaggio del mouse -->
<div class="article-info-popover-wrapper" style="position: relative; display: inline-flex; margin-left: var(--space-xs); padding: 15px 60px 45px 20px; margin-top: -15px; margin-bottom: -45px; margin-right: -60px; margin-left: -20px;">
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; margin-left:var(--space-xs); display:inline-flex; align-items:center; justify-content:center; color:var(--accent-secondary); transition:all 0.15s ease; opacity:1;" title="Rispondi via email (Quota questo articolo)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
</button>
</div>
<div style="display:flex; gap: var(--space-sm); align-items:center;">
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
${a.time_unit ? `
<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ <span class="time-value-display">${parseFloat(a.time_unit)}</span> min</span>
` : ''}
<button class="retrodate-btn-edit btn-edit-article-time" style="background:none; border:none; cursor:pointer; font-size:0.75rem; padding: 2px 4px; display:inline-flex; align-items:center;" title="${a.time_unit ? 'Modifica Tempo' : 'Aggiungi Tempo'}">
${a.time_unit ? '✏️' : '⏱+✏️'}
</button>
<div class="time-editor-popover" style="display:none; position:absolute; right:0; top: 100%; background:var(--bg-card); border:1px solid var(--border-subtle); padding:var(--space-xs); border-radius:var(--radius-sm); z-index:100; box-shadow:var(--shadow-md); margin-top:4px;">
<input type="number" step="any" min="0" class="form-input input-article-time" value="${a.time_unit ? parseFloat(a.time_unit) : 0}" style="width:70px; height:24px; padding:2px 6px; font-size:0.8rem; margin-bottom:4px; display:block;" />
<div style="display:flex; gap:4px; justify-content:flex-end;">
<button class="btn btn-primary btn-sm btn-save-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✓</button>
<button class="btn btn-ghost btn-sm btn-cancel-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✗</button>
</div>
</div>
</div>
<div class="retrodate-container" data-article-id="${a.article_id}">
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
<div class="retrodate-editor" style="display:none;">
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
<div class="retrodate-actions">
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
</div>
</div>
</div>
<!-- Pulsante Info (i) a destra della matitina dell'ora/data con popover -->
<div class="article-info-popover-wrapper" style="position: relative; display: inline-flex;">
<button class="btn-article-info" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; display:inline-flex; align-items:center; justify-content:center; color:var(--accent-primary); transition:all 0.15s ease; font-weight:700; font-size:0.75rem; width:22px; height:22px; position:relative; z-index:2;" title="Dettagli e Persone Email">
ℹ️
</button>
<div class="article-info-popover" style="position: absolute; top: calc(100% - 25px); left: 10px; width: 340px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px; font-size: 0.78rem; box-shadow: var(--shadow-lg); z-index: 120; display: none; text-align: left;">
<div class="article-info-popover" style="position: absolute; top: calc(100% + 4px); right: 0; left: auto; width: 340px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px; font-size: 0.78rem; box-shadow: var(--shadow-lg); z-index: 120; display: none; text-align: left;">
<div style="font-weight: 700; color: var(--text-secondary); margin-bottom: 8px; border-bottom: 1px solid var(--border-subtle); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem;">
<span style="display: flex; align-items: center; gap: 4px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:var(--accent-primary);"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
@@ -2039,38 +2583,6 @@ const TicketDetailView = {
`}
</div>
</div>
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; margin-left:var(--space-xs); display:inline-flex; align-items:center; justify-content:center; color:var(--accent-secondary); transition:all 0.15s ease; opacity:1;" title="Rispondi via email (Quota questo articolo)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
</button>
</div>
<div style="display:flex; gap: var(--space-sm); align-items:center;">
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
${a.time_unit ? `
<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ <span class="time-value-display">${parseFloat(a.time_unit)}</span> min</span>
` : ''}
<button class="retrodate-btn-edit btn-edit-article-time" style="background:none; border:none; cursor:pointer; font-size:0.75rem; padding: 2px 4px; display:inline-flex; align-items:center;" title="${a.time_unit ? 'Modifica Tempo' : 'Aggiungi Tempo'}">
${a.time_unit ? '✏️' : '⏱+✏️'}
</button>
<div class="time-editor-popover" style="display:none; position:absolute; right:0; top: 100%; background:var(--bg-card); border:1px solid var(--border-subtle); padding:var(--space-xs); border-radius:var(--radius-sm); z-index:100; box-shadow:var(--shadow-md); margin-top:4px;">
<input type="number" step="any" min="0" class="form-input input-article-time" value="${a.time_unit ? parseFloat(a.time_unit) : 0}" style="width:70px; height:24px; padding:2px 6px; font-size:0.8rem; margin-bottom:4px; display:block;" />
<div style="display:flex; gap:4px; justify-content:flex-end;">
<button class="btn btn-primary btn-sm btn-save-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✓</button>
<button class="btn btn-ghost btn-sm btn-cancel-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✗</button>
</div>
</div>
</div>
<div class="retrodate-container" data-article-id="${a.article_id}">
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
<div class="retrodate-editor" style="display:none;">
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
<div class="retrodate-actions">
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
</div>
</div>
</div>
</div>
</div>
${a.a_subject ? `<div class="article-subject" style="font-weight:700; font-size:0.95rem; color:var(--text-primary); margin-bottom:var(--space-xs);">${App.escapeHtml(a.a_subject)}</div>` : ''}