Files
otrs-turbo/public/js/views/ticketDetail.js
T

4982 lines
239 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Ticket Detail View
* Shows full ticket info with quick-edit dropdowns, article timeline, and add-note form.
*/
const TicketDetailView = {
ticketId: null,
htmlMode: true,
originalValues: {},
noteAttachments: [],
saveDraft() {
if (!this.ticketId) return;
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.trim() : '';
const time_unit = (document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '') ||
(document.getElementById('note-time-units-bottom') ? document.getElementById('note-time-units-bottom').value.trim() : '');
const ticket_state_id = document.getElementById('note-ticket-state') ? document.getElementById('note-ticket-state').value : '';
const note_create_time = document.getElementById('note-create-time') ? document.getElementById('note-create-time').value : '';
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0 || ticket_state_id !== '' || note_create_time !== '') {
App.saveDraft(this.ticketId, {
type: 'note',
body,
subject,
time_unit,
ticket_state_id,
note_create_time,
attachments: [...this.noteAttachments]
});
} else {
App.clearDraft(this.ticketId, 'note');
}
},
extractLinks(htmlOrText) {
if (!htmlOrText) return [];
const links = [];
const seen = new Set();
// 1. Extract <a href="..."> tags
const anchorRegex = /<a\s+(?:[^>]*?\s+)?href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi;
let match;
while ((match = anchorRegex.exec(htmlOrText)) !== null) {
let url = match[1].trim().replace(/&amp;/g, '&');
let text = match[2].replace(/<[^>]+>/g, '').trim();
if (!text) text = url;
if (/^(javascript|mailto|tel|cid|#|\/api\/tickets\/attachments)/i.test(url)) {
continue;
}
if (!seen.has(url)) {
seen.add(url);
links.push({ url, text });
}
}
// 2. Extract plain HTTP/HTTPS URLs from text
const plainUrlRegex = /(https?:\/\/[^\s<"'>]+)/gi;
while ((match = plainUrlRegex.exec(htmlOrText)) !== null) {
let url = match[1].trim().replace(/[.,;)]+$/, '');
if (/^\/api\/tickets\/attachments/i.test(url)) continue;
if (!seen.has(url)) {
seen.add(url);
links.push({ url, text: url });
}
}
return links;
},
updateNoteAttachmentList() {
const listEl = document.getElementById('note-file-list');
if (!listEl) return;
listEl.innerHTML = this.noteAttachments.map((att, idx) => `
<div class="upload-file-item">
<span>📎</span>
<strong>${App.escapeHtml(att.filename)}</strong>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs);">(${Math.round(att.content.length * 0.75 / 1024)} KB)</span>
<button type="button" class="btn-remove" data-idx="${idx}">✕</button>
</div>
`).join('');
// Bind remove buttons
listEl.querySelectorAll('.btn-remove').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx, 10);
this.noteAttachments.splice(idx, 1);
this.updateNoteAttachmentList();
});
});
},
getFileIcon(filename) {
const ext = (filename || '').split('.').pop().toLowerCase();
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return '📝';
if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return '📊';
if (['ppt', 'pptx', 'odp'].includes(ext)) return '📊';
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return '📦';
if (['pdf'].includes(ext)) return '📄';
if (['txt', 'log', 'json', 'xml', 'html', 'css', 'js', 'md'].includes(ext)) return '📜';
if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) return '🎵';
if (['mp4', 'webm', 'mov', 'avi'].includes(ext)) return '🎬';
return '📎';
},
collectAllTicketAttachments() {
const items = [];
// 1. Gather images from iframe email bodies
document.querySelectorAll('.articles-timeline iframe').forEach(iframe => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) {
doc.querySelectorAll('img').forEach(img => {
if (img.src && !img.src.startsWith('data:image/svg')) {
items.push({
type: 'image',
src: img.src,
filename: img.alt || img.title || 'Immagine articolo',
sizeText: '',
downloadUrl: img.src
});
}
});
}
} catch (_) { }
});
// 2. Gather all attachment badges (Images, PDFs, Word, Excel, etc.)
document.querySelectorAll('.attachment-badge').forEach(badge => {
if (badge.href) {
const filename = badge.querySelector('strong') ? badge.querySelector('strong').textContent : 'Allegato';
const sizeText = badge.querySelector('.attachment-size') ? badge.querySelector('.attachment-size').textContent : '';
const ext = (filename || '').split('.').pop().toLowerCase();
let type = 'file';
if (badge.dataset.isImage === 'true' || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) {
type = 'image';
} else if (ext === 'pdf') {
type = 'pdf';
} else if (['txt', 'log', 'json', 'xml', 'csv', 'html', 'md'].includes(ext)) {
type = 'text';
}
items.push({
type: type,
src: badge.href,
filename: filename,
sizeText: sizeText,
downloadUrl: badge.href
});
}
});
// Deduplicate items by src while maintaining order
const uniqueItems = [];
const seen = new Set();
items.forEach(item => {
if (!seen.has(item.src)) {
seen.add(item.src);
uniqueItems.push(item);
}
});
return uniqueItems;
},
openImageLightbox(initialSrc) {
const itemsList = this.collectAllTicketAttachments();
if (itemsList.length === 0) {
itemsList.push({ type: 'file', src: initialSrc, filename: 'Allegato', downloadUrl: initialSrc });
}
let currentIndex = itemsList.findIndex(item => item.src === initialSrc);
if (currentIndex === -1) {
itemsList.unshift({ type: 'file', src: initialSrc, filename: 'Allegato', downloadUrl: initialSrc });
currentIndex = 0;
}
let overlay = document.getElementById('image-lightbox-overlay');
if (overlay) {
overlay.remove();
}
overlay = document.createElement('div');
overlay.id = 'image-lightbox-overlay';
overlay.style.cssText = `
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.88);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
user-select: none;
`;
overlay.innerHTML = `
<button id="lightbox-close-btn" title="Chiudi (Esc)" style="position: absolute; top: 20px; right: 24px; background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.25); color: white; border-radius: 50%; width: 40px; height: 40px; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; z-index: 10002;">✕</button>
<button id="lightbox-prev-btn" title="Allegato precedente (Freccia Sinistra)" style="position: absolute; left: 24px; top: 50%; transform: translateY(-50%); background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.25); color: white; border-radius: 50%; width: 48px; height: 48px; font-size: 26px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; z-index: 10002;">‹</button>
<button id="lightbox-next-btn" title="Allegato successivo (Freccia Destra)" style="position: absolute; right: 24px; top: 50%; transform: translateY(-50%); background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.25); color: white; border-radius: 50%; width: 48px; height: 48px; font-size: 26px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; z-index: 10002;">›</button>
<div id="lightbox-img-wrapper" style="display: flex; align-items: center; justify-content: center; width: 100%; max-width: 88vw; max-height: 82vh;"></div>
<div id="lightbox-caption" style="position: absolute; bottom: 20px; background: rgba(0, 0, 0, 0.85); border: 1px solid rgba(255, 255, 255, 0.2); padding: 6px 16px; border-radius: 24px; color: white; font-size: 0.82rem; display: flex; align-items: center; gap: 12px; z-index: 10002;">
<span id="lightbox-title-text" style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500;"></span>
<span id="lightbox-counter-text" style="opacity: 0.75; font-size: 0.78rem; font-family: monospace;"></span>
<a id="lightbox-download-btn" href="#" target="_blank" style="color: white; background: var(--accent-primary); padding: 4px 12px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; gap: 6px;" title="Apri nell'applicazione o scarica">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Apri nell'app
</a>
</div>
`;
overlay.tabIndex = -1;
document.body.appendChild(overlay);
overlay.focus();
const wrapper = overlay.querySelector('#lightbox-img-wrapper');
const prevBtn = overlay.querySelector('#lightbox-prev-btn');
const nextBtn = overlay.querySelector('#lightbox-next-btn');
const closeBtn = overlay.querySelector('#lightbox-close-btn');
const titleEl = overlay.querySelector('#lightbox-title-text');
const counterEl = overlay.querySelector('#lightbox-counter-text');
const downloadBtn = overlay.querySelector('#lightbox-download-btn');
const updateDisplay = () => {
const current = itemsList[currentIndex];
let previewHtml = '';
if (current.type === 'image') {
previewHtml = `
<div id="image-viewer-container" style="position: relative; width: 84vw; height: 78vh; background: var(--bg-card); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border-subtle);">
<div id="image-toolbar" style="min-height: 48px; background: rgba(15, 23, 42, 0.95); border-bottom: 1px solid rgba(255, 255, 255, 0.15); display: flex; align-items: center; justify-content: space-between; padding: 6px 14px; color: #ffffff; font-size: 0.82rem; user-select: none; gap: 8px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 6px; flex-wrap: wrap;">
<div id="img-edit-tools" style="display: flex; align-items: center; gap: 4px; background: rgba(255,255,255,0.08); padding: 3px 6px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15);">
<button id="img-tool-select" class="btn btn-ghost btn-sm" title="Seleziona, Sposta, Ridimensiona o Elimina elementi" style="color:#ffffff; height:26px; padding:0 6px; font-size:0.78rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🖐️ Seleziona</button>
<button id="img-tool-draw" class="btn btn-ghost btn-sm active" title="Disegna / Penna libera" style="color:#ffffff; height:26px; padding:0 6px; font-size:0.78rem; background:var(--accent-primary); border-radius:4px; border:none; cursor:pointer;">✏️ Penna</button>
<button id="img-tool-text" class="btn btn-ghost btn-sm" title="Aggiungi Testo" style="color:#ffffff; height:26px; padding:0 6px; font-size:0.78rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🔤 Testo</button>
<button id="img-tool-highlight" class="btn btn-ghost btn-sm" title="Evidenziatore" style="color:#ffffff; height:26px; padding:0 6px; font-size:0.78rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🖍️ Evidenzia</button>
<button id="img-tool-shape" class="btn btn-ghost btn-sm" title="Inserisci Forme" style="color:#ffffff; height:26px; padding:0 6px; font-size:0.78rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">📐 Forme</button>
<button id="img-tool-signature" class="btn btn-ghost btn-sm" title="Apponi Timbro / Firma Utente" style="color:#ffffff; height:26px; padding:0 6px; font-size:0.78rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">✍️ Firma</button>
</div>
<input type="color" id="img-color-picker" value="#ef4444" title="Colore primario" style="width:24px; height:24px; border:none; border-radius:50%; cursor:pointer; background:none;" />
<div id="img-highlight-controls" style="display: none; align-items: center; gap: 4px; padding-left: 4px; border-left: 1px solid rgba(255,255,255,0.2);" title="Intensità Opacità">
<label style="font-size:0.7rem; color:#cbd5e1; display:flex; align-items:center; gap:3px;">
Opacità: <input type="range" id="img-highlight-opacity" min="0.01" max="1.0" step="0.01" value="0.15" style="width:65px; cursor:pointer;" />
<span id="img-highlight-opacity-val" style="font-size:0.7rem; font-family:monospace; color:#38bdf8; min-width:28px;">15%</span>
</label>
</div>
<div id="img-shape-controls" style="display: none; align-items: center; gap: 4px; padding-left: 4px; border-left: 1px solid rgba(255,255,255,0.2);">
<select id="img-shape-type" style="height: 24px; background: #0f172a; color: #fff; border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; font-size: 0.74rem; 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.7rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;">
Riemp. <input type="color" id="img-shape-fill-color" value="#3b82f6" style="width:20px; height:20px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</label>
<label style="font-size:0.7rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;">
Bordo <input type="color" id="img-shape-border-color" value="#1e3a8a" style="width:20px; height:20px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</label>
<label style="font-size:0.7rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;">
Spess. <input type="number" id="img-shape-border-width" min="0" max="30" value="3" style="width:36px; height:22px; background:#0f172a; color:#fff; border:1px solid rgba(255,255,255,0.3); border-radius:4px; font-size:0.74rem; text-align:center;" />
</label>
</div>
<!-- Quick Image Corrections Bar -->
<div id="img-adjustments-bar" style="display: flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15);">
<label style="font-size:0.7rem; color:#cbd5e1; display:flex; align-items:center; gap:3px;" title="Regola Luminosità">
☀️ Lum
<input type="range" id="img-adj-brightness" min="20" max="200" value="100" style="width:55px; cursor:pointer;" />
</label>
<label style="font-size:0.7rem; color:#cbd5e1; display:flex; align-items:center; gap:3px;" title="Regola Contrasto">
🌗 Con
<input type="range" id="img-adj-contrast" min="20" max="200" value="100" style="width:55px; cursor:pointer;" />
</label>
<label style="font-size:0.7rem; color:#cbd5e1; display:flex; align-items:center; gap:3px;" title="Regola Saturazione">
🎨 Sat
<input type="range" id="img-adj-saturate" min="0" max="250" value="100" style="width:55px; cursor:pointer;" />
</label>
<button id="img-btn-rotate" class="btn btn-ghost btn-sm" title="Ruota immagine 90° in senso orario" style="color:#ffffff; height:24px; padding:0 5px; font-size:0.72rem; background:transparent; border:none; cursor:pointer;">🔄 90°</button>
<button id="img-btn-grayscale" class="btn btn-ghost btn-sm" title="Filtro Bianco e Nero / Grayscale" style="color:#ffffff; height:24px; padding:0 5px; font-size:0.72rem; background:transparent; border:none; cursor:pointer;">🔳 B/N</button>
<button id="img-btn-reset-adj" class="btn btn-ghost btn-sm" title="Ripristina filtri immagine originali" style="color:#cbd5e1; height:24px; padding:0 5px; font-size:0.72rem; background:transparent; border:none; cursor:pointer;">⚡ Reset</button>
</div>
<button id="img-tool-clear" class="btn btn-ghost btn-sm" title="Cancella annotazioni inserite sull'immagine" style="color:#f87171; height:26px; padding:0 6px; font-size:0.76rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🗑️ Pulisci</button>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<button id="img-save-edited" class="btn btn-primary btn-sm" style="height:28px; padding:0 12px; font-size:0.78rem; font-weight:600; display:inline-flex; align-items:center; gap:4px; background:var(--accent-primary); color:#ffffff; border:none; border-radius:6px; cursor:pointer;" title="Scarica l'immagine modificata (Ctrl+Enter)">
💾 Scarica Immagine Modificata
</button>
</div>
</div>
<!-- Canvas Container -->
<div id="image-canvas-wrapper" style="flex: 1; overflow: auto; display: flex; align-items: center; justify-content: center; padding: 16px; background: #1e293b; position: relative;">
<div id="image-canvas-container" style="position: relative; box-shadow: 0 10px 40px rgba(0,0,0,0.6); display: inline-block; border-radius: 4px; overflow: hidden;">
<canvas id="image-base-canvas" style="display: block; border-radius: 4px;"></canvas>
<canvas id="image-draw-canvas" style="position: absolute; top: 0; left: 0; border-radius: 4px; cursor: crosshair; z-index: 10; touch-action: none;"></canvas>
</div>
</div>
</div>
`;
} else if (current.type === 'pdf') {
previewHtml = `
<div id="pdf-viewer-container" style="position: relative; width: 84vw; height: 78vh; background: var(--bg-card); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border-subtle);">
<div id="pdf-toolbar" style="height: 48px; min-height: 48px; background: rgba(15, 23, 42, 0.95); border-bottom: 1px solid rgba(255, 255, 255, 0.15); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; color: #ffffff; font-size: 0.82rem; user-select: none; gap: 12px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 10px;">
<span id="pdf-page-num" style="font-family: monospace; font-weight: 700; background: rgba(255,255,255,0.15); color: #ffffff; padding: 4px 12px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.2);">Pagina 1 / 1</span>
<!-- Editing Tools -->
<div id="pdf-edit-tools" style="display: flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.08); padding: 3px 8px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15);">
<button id="pdf-tool-select" class="btn btn-ghost btn-sm" title="Seleziona, Sposta, Ridimensiona o Elimina elementi" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🖐️ Seleziona</button>
<button id="pdf-tool-draw" class="btn btn-ghost btn-sm active" title="Disegna / Penna libera" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:var(--accent-primary); border-radius:4px; border:none; cursor:pointer;">✏️ Penna</button>
<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;" />
<div id="pdf-highlight-controls" style="display: none; align-items: center; gap: 4px; padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.2);" title="Intensità Opacità">
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:4px;">
Opacità: <input type="range" id="pdf-highlight-opacity" min="0.01" max="1.0" step="0.01" value="0.15" style="width:70px; cursor:pointer;" />
<span id="pdf-highlight-opacity-val" style="font-size:0.72rem; font-family:monospace; color:#38bdf8; min-width:30px;">15%</span>
</label>
</div>
<!-- Shape controls container -->
<div id="pdf-shape-controls" style="display: none; align-items: center; gap: 6px; padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.2);">
<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.
<input type="color" id="pdf-shape-fill-color" value="#3b82f6" style="width:22px; height:22px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</label>
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;" title="Colore del Bordo">
Bordo
<input type="color" id="pdf-shape-border-color" value="#1e3a8a" style="width:22px; height:22px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</label>
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;" title="Spessore del Bordo">
Spess.
<input type="number" id="pdf-shape-border-width" min="0" max="30" value="3" style="width:40px; height:24px; background:#0f172a; color:#fff; border:1px solid rgba(255,255,255,0.3); border-radius:4px; font-size:0.76rem; padding:0 4px; text-align:center;" />
</label>
</div>
<button id="pdf-tool-clear" class="btn btn-ghost btn-sm" title="Cancella tutte le modifiche apportate a questa pagina" style="color:#f87171; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🗑️ Pulisci Pagina</button>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 0.76rem; color: #cbd5e1; font-weight: 500;">
📜 Scorrimento continuo | ‹ › Allegati | Esc per chiudere
</span>
<button id="pdf-save-edited" class="btn btn-primary btn-sm" style="height:30px; padding:0 14px; font-size:0.8rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:var(--accent-primary); color:#ffffff; border:none; border-radius:6px; cursor:pointer;" title="Scarica il PDF modificato con le tue annotazioni">
💾 Scarica PDF Modificato
</button>
</div>
</div>
<div id="pdf-canvas-wrapper" style="flex: 1; overflow-y: auto; overflow-x: auto; display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 20px 16px; background: #334155; scroll-behavior: smooth;">
<div id="pdf-loading" style="color: #ffffff; display: flex; align-items: center; gap: 8px; margin-top: 40px; font-weight:500;">
<div class="spinner" style="width:20px; height:20px; border-width:2px;"></div>
<span>Rendering documento PDF in corso...</span>
</div>
</div>
</div>
`;
} else if (current.type === 'text') {
const attId = (current.src.match(/\/attachments\/(\d+)/) || [])[1];
previewHtml = `
<div id="text-viewer-container" style="position: relative; width: 84vw; height: 78vh; background: var(--bg-card); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border-subtle);">
<div id="text-toolbar" style="height: 48px; min-height: 48px; background: rgba(15, 23, 42, 0.95); border-bottom: 1px solid rgba(255, 255, 255, 0.15); display: flex; align-items: center; justify-content: space-between; padding: 0 16px; color: #ffffff; font-size: 0.82rem; user-select: none; gap: 12px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-weight: 700; color: #ffffff;">📝 Documento di Testo (Modificabile)</span>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
${attId ? `
<button id="text-save-btn" class="btn btn-primary btn-sm" style="height:30px; padding:0 14px; font-size:0.8rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:var(--accent-primary); color:#ffffff; border:none; border-radius:6px; cursor:pointer;" title="Salva le modifiche apportate al file di testo">
💾 Salva Modifiche
</button>
` : ''}
</div>
</div>
<div style="flex: 1; position: relative; padding: 12px; background: #0f172a; display: flex;">
<textarea id="text-editor-textarea" style="flex: 1; width: 100%; height: 100%; background: #090d16; color: #e2e8f0; font-family: consolas, Monaco, monospace, sans-serif; font-size: 0.88rem; line-height: 1.5; border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; padding: 14px; resize: none; outline: none;" placeholder="Caricamento contenuto di testo..."></textarea>
</div>
</div>
`;
} else {
const icon = this.getFileIcon(current.filename);
previewHtml = `
<div style="width: 480px; max-width: 88vw; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 32px 24px; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,0.6); color: var(--text-primary); display: flex; flex-direction: column; align-items: center; gap: 16px;">
<div style="font-size: 56px; line-height: 1;">${icon}</div>
<div>
<div style="font-size: 1.1rem; font-weight: 700; color: var(--text-primary); word-break: break-all; margin-bottom: 4px;">
${App.escapeHtml(current.filename)}
</div>
<div style="font-size: 0.82rem; color: var(--text-muted);">
${App.escapeHtml(current.sizeText || 'Allegato documento')}
</div>
</div>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 0; line-height: 1.4;">
Scegli se aprire il file con l'applicazione predefinita del tuo computer (Word, Excel, PowerPoint, ecc.) oppure scaricarne una copia locale.
</p>
<div style="display:flex; gap:10px; flex-wrap:wrap; justify-content:center;">
<button id="doc-open-system-btn" class="btn btn-primary" style="padding: 10px 18px; font-size: 0.88rem; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; border-radius: var(--radius-md); border:none; cursor:pointer;">
🚀 Apri con app di sistema
</button>
<a href="${current.downloadUrl}" download="${App.escapeHtml(current.filename)}" target="_blank" class="btn btn-ghost" style="padding: 10px 18px; font-size: 0.88rem; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; border-radius: var(--radius-md); border:1px solid var(--border-subtle);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px; height:16px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Scarica copia
</a>
</div>
</div>
`;
}
wrapper.innerHTML = previewHtml;
this.currentFilename = current.filename || 'documento.pdf';
titleEl.textContent = current.filename || 'Allegato';
counterEl.textContent = `${currentIndex + 1} / ${itemsList.length}`;
downloadBtn.href = current.downloadUrl;
downloadBtn.download = current.filename || '';
if (itemsList.length <= 1) {
prevBtn.style.display = 'none';
nextBtn.style.display = 'none';
} else {
prevBtn.style.display = 'flex';
nextBtn.style.display = 'flex';
}
if (current.type === 'pdf') {
this.initPdfJsViewer(current.src);
} else if (current.type === 'image') {
this.initImageEditor(current.src, current.filename);
} else if (current.type === 'text') {
const textarea = document.getElementById('text-editor-textarea');
const saveBtn = document.getElementById('text-save-btn');
const attId = (current.src.match(/\/attachments\/(\d+)/) || [])[1];
if (textarea) {
fetch(current.src)
.then(r => r.text())
.then(txt => { textarea.value = txt; })
.catch(err => { textarea.value = 'Errore nel caricamento del file: ' + err.message; });
}
if (saveBtn && attId && textarea) {
saveBtn.addEventListener('click', async () => {
try {
saveBtn.disabled = true;
saveBtn.innerHTML = '💾 Salvataggio...';
await App.api(`/api/tickets/attachments/${attId}`, {
method: 'PUT',
body: JSON.stringify({ content: textarea.value })
});
Toast.success('File di testo salvato con successo!');
} catch (err) {
Toast.error('Errore durante il salvataggio: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '💾 Salva Modifiche';
}
});
}
} else {
const openSysBtn = document.getElementById('doc-open-system-btn');
const attId = (current.src.match(/\/attachments\/(\d+)/) || [])[1];
if (openSysBtn && attId) {
openSysBtn.addEventListener('click', async () => {
try {
openSysBtn.disabled = true;
openSysBtn.innerHTML = '🚀 Apertura in corso...';
await App.api(`/api/tickets/attachments/${attId}/open`, { method: 'POST' });
Toast.success('File aperto con l\'applicazione di sistema!');
} catch (err) {
Toast.error('Impossibile aprire il file: ' + err.message);
} finally {
openSysBtn.disabled = false;
openSysBtn.innerHTML = '🚀 Apri con app di sistema';
}
});
}
}
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);
window.removeEventListener('blur', handleWindowBlur);
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 200);
};
// Helper to generate filename with optional timestamp prefix if already downloaded in current session
const getOutputFilename = (originalName) => this.getOutputFilename(originalName);
const savePdfDocument = async () => {
if (!window.PDFLib) {
Toast.error('Libreria salvataggio PDF-Lib non caricata');
return;
}
const saveBtn = document.getElementById('pdf-save-edited');
try {
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = 'Generazione PDF...';
}
const pdfDocLib = await window.PDFLib.PDFDocument.load(this.currentRawPdfBytes);
const libPages = pdfDocLib.getPages();
const totalPages = libPages.length;
for (let num = 1; num <= totalPages; num++) {
const baseCanvas = document.getElementById(`pdf-canvas-page-${num}`);
const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`);
if (!baseCanvas || !drawCanvas) continue;
// Check if drawCanvas has any annotations
const drawCtx = drawCanvas.getContext('2d');
const imgData = drawCtx.getImageData(0, 0, drawCanvas.width, drawCanvas.height);
const hasDrawing = imgData.data.some(channel => channel !== 0);
if (hasDrawing && libPages[num - 1]) {
// Merge base page and drawings onto a temporary canvas
const tempCanvas = document.createElement('canvas');
tempCanvas.width = baseCanvas.width;
tempCanvas.height = baseCanvas.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(baseCanvas, 0, 0);
tempCtx.globalCompositeOperation = 'multiply';
tempCtx.drawImage(drawCanvas, 0, 0);
tempCtx.globalCompositeOperation = 'source-over';
const dataUrl = tempCanvas.toDataURL('image/jpeg', 0.92);
const jpgImage = await pdfDocLib.embedJpg(dataUrl);
const targetPage = libPages[num - 1];
const { width, height } = targetPage.getSize();
targetPage.drawImage(jpgImage, {
x: 0,
y: 0,
width: width,
height: height,
});
}
}
const modifiedBytes = await pdfDocLib.save();
const blob = new Blob([modifiedBytes], { type: 'application/pdf' });
const downloadUrl = URL.createObjectURL(blob);
const outName = getOutputFilename(this.currentFilename);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = outName;
document.body.appendChild(a);
a.click();
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);
Toast.error('Errore durante il salvataggio: ' + saveErr.message);
} finally {
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = '💾 Scarica PDF Modificato';
}
}
};
const handleKeydown = (e) => {
const currentItem = itemsList[currentIndex];
if (currentItem && App.isShortcutTriggered(e, 'pdf_save_edited')) {
e.preventDefault();
e.stopPropagation();
if (currentItem.type === 'pdf') {
savePdfDocument();
} else if (currentItem.type === 'image') {
this.saveImageDocument(currentItem.filename);
} else {
// Non-PDF/image attachment: 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;
}
// If user is editing text in an input, textarea or contenteditable element, allow standard cursor navigation for arrow keys etc.
const activeTag = document.activeElement ? document.activeElement.tagName.toUpperCase() : '';
const isInputOrTextarea = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable);
if (isInputOrTextarea) {
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeLightbox();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
showPrev();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
showNext();
} else if (e.key === 'ArrowUp' && window.currentPdfDoc) {
e.preventDefault();
const prevPageBtn = document.getElementById('pdf-prev-page');
if (prevPageBtn) prevPageBtn.click();
} else if (e.key === 'ArrowDown' && window.currentPdfDoc) {
e.preventDefault();
const nextPageBtn = document.getElementById('pdf-next-page');
if (nextPageBtn) nextPageBtn.click();
}
};
// When clicking anywhere on the lightbox overlay or pressing ESC, ensure focus returns to main window
const handleWindowBlur = () => {
window.focus();
};
window.addEventListener('blur', handleWindowBlur);
document.addEventListener('keydown', handleKeydown, true);
window.addEventListener('keydown', handleKeydown, true);
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); showPrev(); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); showNext(); });
closeBtn.addEventListener('click', (e) => { e.stopPropagation(); closeLightbox(); });
overlay.addEventListener('click', (e) => {
if (e.target === overlay || e.target.id === 'lightbox-img-wrapper') {
closeLightbox();
}
});
updateDisplay();
overlay.offsetHeight;
overlay.style.opacity = '1';
},
async initPdfJsViewer(url) {
if (!window.pdfjsLib) {
console.warn('PDF.js library not loaded');
return;
}
try {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const response = await fetch(url);
const pdfBytes = await response.arrayBuffer();
this.currentRawPdfBytes = pdfBytes.slice(0); // copy buffer for pdf-lib export
const loadingTask = window.pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) });
const pdfDoc = await loadingTask.promise;
window.currentPdfDoc = pdfDoc;
const totalPages = pdfDoc.numPages;
const pageNumEl = document.getElementById('pdf-page-num');
const loadingEl = document.getElementById('pdf-loading');
const wrapper = document.getElementById('pdf-canvas-wrapper');
if (!wrapper) return;
if (pageNumEl) pageNumEl.textContent = `Pagina 1 / ${totalPages}`;
let currentTool = 'draw'; // 'draw' | 'text' | 'highlight' | 'shape' | 'signature'
let currentColor = '#ef4444';
let currentHighlightOpacity = 0.15;
let mousePos = null;
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 highlightControls = document.getElementById('pdf-highlight-controls');
const highlightOpacityInput = document.getElementById('pdf-highlight-opacity');
const highlightOpacityVal = document.getElementById('pdf-highlight-opacity-val');
const colorPicker = document.getElementById('pdf-color-picker');
const toolClearBtn = document.getElementById('pdf-tool-clear');
const saveEditedBtn = document.getElementById('pdf-save-edited');
if (highlightOpacityInput && highlightOpacityVal) {
highlightOpacityInput.oninput = (e) => {
currentHighlightOpacity = parseFloat(e.target.value);
highlightOpacityVal.textContent = `${Math.round(currentHighlightOpacity * 100)}%`;
};
}
const setActiveTool = (tool, activeBtn) => {
currentTool = tool;
const allToolBtns = [toolSelectBtn, toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, toolSigBtn];
allToolBtns.forEach(b => {
if (b) {
b.style.background = 'transparent';
b.classList.remove('active');
}
});
if (activeBtn) {
activeBtn.style.background = 'var(--accent-primary)';
activeBtn.classList.add('active');
}
if (shapeControls) {
shapeControls.style.display = tool === 'shape' ? 'flex' : 'none';
}
if (highlightControls) {
highlightControls.style.display = (tool === 'draw' || tool === 'highlight') ? '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();
setActiveTool('shape', toolShapeBtn);
let shapePopup = document.getElementById('pdf-shape-picker-popup');
if (shapePopup) {
shapePopup.remove();
return;
}
let allShapes = [];
try {
allShapes = await App.api('/api/config/user-shapes');
} catch (_) { }
const btnRect = toolShapeBtn.getBoundingClientRect();
shapePopup = document.createElement('div');
shapePopup.id = 'pdf-shape-picker-popup';
shapePopup.style.cssText = `
position: fixed;
left: ${btnRect.left}px;
top: ${btnRect.bottom + 6}px;
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: 220px;
max-height: 280px;
overflow-y: auto;
user-select: none;
`;
shapePopup.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 Forma</span>
<span style="cursor:pointer; opacity:0.7;" id="shape-popup-close">✕</span>
</div>
<div style="margin-bottom: 8px;">
<label style="font-size:0.72rem; color:#94a3b8; display:block; margin-bottom:4px;">Forme Base</label>
<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>
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 6px;">
<label style="font-size:0.72rem; color:#94a3b8; display:block; margin-bottom:4px;">Forme Salvate in SQLite</label>
${allShapes.length > 0 ? `
<div style="display:flex; flex-direction:column; gap:4px;">
${allShapes.map(sh => `
<button class="preset-shape-btn" data-id="${sh.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<span style="display:inline-block; width:16px; height:12px; border-radius:${sh.shape_type === 'circle' ? '50%' : '2px'}; background:${sh.fill_color === 'transparent' ? 'transparent' : sh.fill_color}; border:${Math.min(sh.border_width, 3)}px solid ${sh.border_color}; box-sizing:border-box;"></span>
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(sh.name)}</span>
<span>${sh.is_favorite ? '⭐' : ''}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.72rem; color:#94a3b8; font-style:italic;">Nessuna forma salvata. Puoi crearne su 'Configurazioni Utente'.</div>
`}
</div>
`;
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;
shapePopup.querySelectorAll('.base-shape-btn').forEach(btn => {
btn.onclick = (eBtn) => {
eBtn.stopPropagation();
const typeSelect = document.getElementById('pdf-shape-type');
if (typeSelect) typeSelect.value = btn.dataset.type;
closePopup();
};
});
shapePopup.querySelectorAll('.preset-shape-btn').forEach(btn => {
btn.onclick = (eBtn) => {
eBtn.stopPropagation();
const sh = allShapes.find(s => s.id == btn.dataset.id);
if (sh) {
const typeSelect = document.getElementById('pdf-shape-type');
const fillPicker = document.getElementById('pdf-shape-fill-color');
const borderPicker = document.getElementById('pdf-shape-border-color');
const borderWidth = document.getElementById('pdf-shape-border-width');
if (typeSelect) typeSelect.value = sh.shape_type;
if (fillPicker && sh.fill_color !== 'transparent') fillPicker.value = sh.fill_color;
if (borderPicker) borderPicker.value = sh.border_color;
if (borderWidth) borderWidth.value = sh.border_width;
Toast.success(`Forma "${sh.name}" selezionata! Clicca sul PDF per posizionarla.`);
}
closePopup();
};
});
const closeOnOutsideClick = (evt) => {
if (!shapePopup.contains(evt.target) && evt.target !== toolShapeBtn) {
closePopup();
document.removeEventListener('mousedown', closeOnOutsideClick, true);
}
};
setTimeout(() => document.addEventListener('mousedown', closeOnOutsideClick, true), 50);
};
}
if (colorPicker) colorPicker.onchange = (e) => { currentColor = e.target.value; };
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: 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';
drawCanvas.dataset.pageNum = num;
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);
}
if (loadingEl) loadingEl.style.display = 'none';
// Render pages & bind drawing events on overlay canvases
for (let num = 1; num <= totalPages; num++) {
try {
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;
const baseCtx = baseCanvas.getContext('2d');
const renderContext = {
canvasContext: baseCtx,
viewport: viewport
};
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');
// Page elements array: [{ id, type: 'stroke'|'text'|'image', x, y, width, height, color, size, text, img, strokePoints }]
const pageObjects = [];
drawCanvas.pageObjects = pageObjects;
let selectedObject = null;
let isDraggingObj = false;
let isResizingObj = false;
let resizeHandleIndex = -1; // 0..7
let dragOffsetX = 0;
let dragOffsetY = 0;
let isDrawingStroke = false;
let currentStrokePoints = [];
let currentBrushType = 'pencil'; // 'pencil' | 'brush' | 'highlight'
let currentBrushSize = 3;
const redrawPageObjects = () => {
drawCtx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
pageObjects.forEach(obj => {
drawCtx.save();
if (obj.type === 'stroke') {
if (obj.points && obj.points.length > 0) {
drawCtx.beginPath();
drawCtx.moveTo(obj.points[0].x, obj.points[0].y);
obj.points.forEach(p => drawCtx.lineTo(p.x, p.y));
drawCtx.strokeStyle = obj.color;
drawCtx.lineWidth = obj.size;
if (obj.brushType === 'highlight' || obj.type === 'highlight') {
drawCtx.globalCompositeOperation = 'multiply';
drawCtx.globalAlpha = obj.opacity !== undefined ? obj.opacity : 0.15;
drawCtx.lineCap = 'square';
} else {
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.globalAlpha = obj.opacity !== undefined ? obj.opacity : 1.0;
drawCtx.lineCap = 'round';
}
drawCtx.lineJoin = 'round';
drawCtx.stroke();
}
} else if (obj.type === 'text') {
drawCtx.font = `bold ${obj.size || 16}px Inter, sans-serif`;
drawCtx.fillStyle = obj.color;
drawCtx.globalAlpha = 1.0;
drawCtx.fillText(obj.text, obj.x, obj.y + (obj.size || 16));
} else if (obj.type === 'image') {
drawCtx.globalAlpha = 1.0;
if (obj.img) {
drawCtx.drawImage(obj.img, obj.x, obj.y, obj.width, obj.height);
}
} else if (obj.type === 'shape') {
drawCtx.globalAlpha = 1.0;
drawCtx.beginPath();
if (obj.shapeType === 'circle') {
const rx = obj.width / 2;
const ry = obj.height / 2;
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();
}
}
}
drawCtx.restore();
// Draw selection box & resize/endpoint handles if selected
if (selectedObject === obj) {
drawCtx.save();
drawCtx.strokeStyle = '#3b82f6';
drawCtx.lineWidth = 1.5;
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, 5, 0, Math.PI * 2);
drawCtx.fill();
drawCtx.stroke();
});
drawCtx.restore();
}
});
// Draw brush size circle indicator under mouse cursor when drawing/highlighting
if (mousePos && (currentTool === 'draw' || currentTool === 'highlight')) {
const currentSize = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const radius = Math.max(2, currentSize / 2);
drawCtx.save();
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.beginPath();
drawCtx.arc(mousePos.x, mousePos.y, radius, 0, Math.PI * 2);
drawCtx.strokeStyle = 'rgba(0, 0, 0, 0.7)';
drawCtx.lineWidth = 1.5;
drawCtx.stroke();
drawCtx.beginPath();
drawCtx.arc(mousePos.x, mousePos.y, radius, 0, Math.PI * 2);
drawCtx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
drawCtx.lineWidth = 1;
drawCtx.stroke();
drawCtx.restore();
}
};
const getObjectHandles = (obj) => {
const x = obj.x;
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
{ x: x + w, y: y }, // 2: Top-Right
{ x: x + w, y: y + h / 2 }, // 3: Middle-Right
{ x: x + w, y: y + h }, // 4: Bottom-Right
{ x: x + w / 2, y: y + h }, // 5: Bottom-Center
{ x: x, y: y + h }, // 6: Bottom-Left
{ x: x, y: y + h / 2 } // 7: Middle-Left
];
};
const hitTestObject = (x, y) => {
for (let i = pageObjects.length - 1; i >= 0; i--) {
const obj = pageObjects[i];
if (x >= obj.x - 6 && x <= obj.x + obj.width + 6 && y >= obj.y - 6 && y <= obj.y + obj.height + 6) {
return obj;
}
}
return null;
};
const hitTestHandle = (obj, x, y) => {
if (!obj) return -1;
const handles = getObjectHandles(obj);
for (let i = 0; i < handles.length; i++) {
const h = handles[i];
const dist = Math.hypot(x - h.x, y - h.y);
if (dist <= 7) return i;
}
return -1;
};
// Delete selected object on Delete or Backspace key
const deleteSelectedObject = () => {
if (selectedObject) {
const idx = pageObjects.indexOf(selectedObject);
if (idx !== -1) {
pageObjects.splice(idx, 1);
selectedObject = null;
redrawPageObjects();
Toast.info('Elemento rimosso');
}
}
};
document.addEventListener('keydown', (e) => {
if (selectedObject && (e.key === 'Delete' || e.key === 'Backspace')) {
if (document.activeElement && ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
e.preventDefault();
deleteSelectedObject();
}
});
// Ctrl + Right Click Context Menu Handler
drawCanvas.oncontextmenu = async (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Fetch signatures and shapes from SQLite
let signatures = [];
let favoriteShapes = [];
try {
const [sigRes, shapeRes] = await Promise.all([
App.api('/api/config/user-signatures'),
App.api('/api/config/user-shapes')
]);
signatures = sigRes || [];
favoriteShapes = (shapeRes || []).filter(s => s.is_favorite);
} catch (_) { }
let menuOverlay = document.getElementById('pdf-context-menu-popup');
if (menuOverlay) menuOverlay.remove();
menuOverlay = document.createElement('div');
menuOverlay.id = 'pdf-context-menu-popup';
menuOverlay.style.cssText = `
position: fixed;
left: ${e.clientX}px;
top: ${e.clientY}px;
z-index: 20000;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
color: #ffffff;
font-size: 0.8rem;
min-width: 260px;
user-select: none;
`;
menuOverlay.innerHTML = `
<div style="font-weight: 700; margin-bottom: 8px; 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>⚙️ Strumenti Rapidi PDF</span>
<span style="cursor:pointer; opacity:0.7;" id="ctx-close">✕</span>
</div>
<div style="margin-bottom: 10px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:4px;">Tipo di Strumento</label>
<div style="display:flex; gap:4px; flex-wrap:wrap;">
<button class="ctx-btn ${currentTool === 'draw' && currentBrushType === 'pencil' ? 'active' : ''}" data-type="pencil" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'draw' && currentBrushType === 'pencil' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">✏️ Matita</button>
<button class="ctx-btn ${currentTool === 'draw' && currentBrushType === 'brush' ? 'active' : ''}" data-type="brush" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'draw' && currentBrushType === 'brush' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">🖌️ Pennello</button>
<button class="ctx-btn ${currentTool === 'highlight' ? 'active' : ''}" data-type="highlight" style="flex:1; min-width:65px; padding:4px; font-size:0.75rem; background:${currentTool === 'highlight' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">🖍️ Evidenzia</button>
<button class="ctx-btn ${currentTool === 'shape' ? 'active' : ''}" data-type="shape" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'shape' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">📐 Forme</button>
</div>
</div>
<div style="margin-bottom: 10px; display:flex; gap:8px; align-items:center;">
<div style="flex:1;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:2px;">
<label style="font-size:0.75rem; color:#94a3b8;">Dimensione tratto</label>
<span id="ctx-brush-val-badge" style="font-size:0.72rem; font-family:monospace; color:#38bdf8; font-weight:700;">${currentBrushSize}px</span>
</div>
<div style="display:flex; align-items:center; gap:6px;">
<input type="range" id="ctx-brush-size" min="1" max="40" value="${currentBrushSize}" style="flex:1; cursor:pointer;" />
<div id="ctx-brush-preview-box" title="Anteprima dimensione reale" style="width:26px; height:26px; border-radius:4px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.2); display:flex; align-items:center; justify-content:center; flex-shrink:0;">
<div id="ctx-brush-preview-circle" style="width:${Math.max(2, currentBrushSize)}px; height:${Math.max(2, currentBrushSize)}px; max-width:22px; max-height:22px; border-radius:50%; background:${currentColor}; transition:all 0.1s ease;"></div>
</div>
</div>
</div>
<div>
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:2px;">Colore</label>
<input type="color" id="ctx-color" value="${currentColor}" style="width:26px; height:26px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</div>
</div>
<!-- Favorite Shapes Section -->
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px; margin-bottom: 8px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:6px;">📐 Forme Preferite Salvate</label>
${favoriteShapes.length > 0 ? `
<div style="max-height: 100px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
${favoriteShapes.map(sh => `
<button class="ctx-shape-btn" data-id="${sh.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<span style="display:inline-block; width:16px; height:12px; border-radius:${sh.shape_type === 'circle' ? '50%' : '2px'}; background:${sh.fill_color === 'transparent' ? 'transparent' : sh.fill_color}; border:${Math.min(sh.border_width, 3)}px solid ${sh.border_color}; box-sizing:border-box;"></span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(sh.name)}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic;">Nessuna forma preferita salvata.</div>
`}
</div>
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:6px;">✍️ Inserisci Firma / Immagine Utente</label>
${signatures.length > 0 ? `
<div style="max-height: 120px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
${signatures.map(s => `
<button class="ctx-sig-btn" data-id="${s.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<img src="${s.image_data}" style="width:28px; height:20px; object-fit:contain; background:#fff; border-radius:2px; padding:1px;" />
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(s.name)}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic;">Nessuna firma salvata in SQLite.</div>
`}
</div>
`;
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;
menuOverlay.querySelectorAll('.ctx-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const type = btn.dataset.type;
menuOverlay.querySelectorAll('.ctx-btn').forEach(b => {
b.style.background = 'rgba(255,255,255,0.1)';
b.classList.remove('active');
});
btn.style.background = 'var(--accent-primary)';
btn.classList.add('active');
if (type === 'pencil') {
currentBrushType = 'pencil';
currentBrushSize = 3;
setActiveTool('draw', toolDrawBtn);
} else if (type === 'brush') {
currentBrushType = 'brush';
currentBrushSize = 8;
setActiveTool('draw', toolDrawBtn);
} else if (type === 'highlight') {
currentBrushType = 'highlight';
currentBrushSize = 20;
setActiveTool('highlight', toolHighlightBtn);
} else if (type === 'shape') {
setActiveTool('shape', toolShapeBtn);
}
const valBadge = menuOverlay.querySelector('#ctx-brush-val-badge');
if (valBadge) valBadge.textContent = `${currentBrushSize}px`;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) {
pCircle.style.width = `${Math.max(2, currentBrushSize)}px`;
pCircle.style.height = `${Math.max(2, currentBrushSize)}px`;
}
if (sizeInput) sizeInput.value = currentBrushSize;
};
});
const sizeInput = menuOverlay.querySelector('#ctx-brush-size');
if (sizeInput) {
sizeInput.oninput = (eVal) => {
eVal.stopPropagation();
const val = parseInt(eVal.target.value, 10);
currentBrushSize = val;
const valBadge = menuOverlay.querySelector('#ctx-brush-val-badge');
if (valBadge) valBadge.textContent = `${val}px`;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) {
pCircle.style.width = `${Math.max(2, val)}px`;
pCircle.style.height = `${Math.max(2, val)}px`;
}
// 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();
}
};
}
const ctxColorPicker = menuOverlay.querySelector('#ctx-color');
if (ctxColorPicker) {
ctxColorPicker.onchange = (eVal) => {
eVal.stopPropagation();
currentColor = eVal.target.value;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) pCircle.style.background = currentColor;
if (colorPicker) colorPicker.value = currentColor;
};
}
menuOverlay.querySelectorAll('.ctx-shape-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const shId = btn.dataset.id;
const sh = favoriteShapes.find(s => s.id == shId);
if (sh) {
const defaultW = 130;
const defaultH = 85;
const newObj = {
id: Date.now(),
type: 'shape',
shapeType: sh.shape_type,
x: clickX - defaultW / 2,
y: clickY - defaultH / 2,
width: defaultW,
height: defaultH,
fillColor: sh.fill_color,
borderColor: sh.border_color,
borderWidth: sh.border_width
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Forma "${sh.name}" inserita!`);
}
closeCtx();
};
});
menuOverlay.querySelectorAll('.ctx-sig-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const sigId = btn.dataset.id;
const sig = signatures.find(s => s.id == sigId);
if (sig) {
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: clickX - targetWidth / 2,
y: clickY - targetHeight / 2,
width: targetWidth,
height: targetHeight
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Firma "${sig.name}" aggiunta al PDF! Usa 'Seleziona' per trascinare o ridimensionare.`);
};
img.src = sig.image_data;
}
closeCtx();
};
});
const closeOnOutside = (evt) => {
if (!menuOverlay.contains(evt.target)) {
closeCtx();
document.removeEventListener('mousedown', closeOnOutside, true);
}
};
setTimeout(() => document.addEventListener('mousedown', closeOnOutside, true), 50);
}
};
// Tool Select Button Handler
const toolSelectBtn = document.getElementById('pdf-tool-select');
if (toolSelectBtn) {
toolSelectBtn.onclick = (e) => {
e.stopPropagation();
setActiveTool('select', toolSelectBtn);
drawCanvas.style.cursor = 'default';
};
}
drawCanvas.onmousedown = (e) => {
if (e.button !== 0) return; // Only primary click
const existingPopup = document.getElementById('pdf-context-menu-popup') || document.getElementById('img-context-menu-popup');
if (existingPopup) existingPopup.remove();
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 1. Check if clicking on resize handles of currently selected object (any tool mode)
if (selectedObject) {
const hIdx = hitTestHandle(selectedObject, x, y);
if (hIdx !== -1) {
isResizingObj = true;
resizeHandleIndex = hIdx;
return;
}
}
// 2. Check if clicking directly on an existing object border/body (any tool mode)
const hitObj = hitTestObject(x, y);
if (hitObj) {
selectedObject = hitObj;
isDraggingObj = true;
dragOffsetX = x - hitObj.x;
dragOffsetY = y - hitObj.y;
redrawPageObjects();
return;
}
// If clicked empty space, deselect current object
if (selectedObject) {
selectedObject = null;
redrawPageObjects();
}
if (currentTool === 'select') {
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();
const initialText = existingTextObj ? existingTextObj.text : '';
const textColor = existingTextObj ? existingTextObj.color : currentColor;
const textSize = existingTextObj ? existingTextObj.size : (currentBrushSize > 8 ? currentBrushSize : 16);
inlineInput = document.createElement('input');
inlineInput.id = 'pdf-inline-text-input';
inlineInput.type = 'text';
inlineInput.value = initialText;
inlineInput.placeholder = 'Scrivi testo...';
inlineInput.style.cssText = `
position: absolute;
left: ${clientX}px;
top: ${clientY}px;
z-index: 20000;
background: var(--bg-card);
color: ${textColor};
border: 2px solid var(--accent-primary);
border-radius: 4px;
padding: 2px 6px;
font-family: Inter, sans-serif;
font-weight: 700;
font-size: ${textSize}px;
box-shadow: 0 4px 14px rgba(0,0,0,0.5);
outline: none;
min-width: 140px;
`;
document.body.appendChild(inlineInput);
inlineInput.focus();
if (existingTextObj) inlineInput.select();
let isCommitted = false;
const commitInlineText = () => {
if (isCommitted) return;
isCommitted = true;
const text = inlineInput ? inlineInput.value.trim() : '';
if (inlineInput) inlineInput.remove();
if (existingTextObj) {
if (text) {
existingTextObj.text = text;
drawCtx.font = `bold ${existingTextObj.size}px Inter, sans-serif`;
existingTextObj.width = drawCtx.measureText(text).width;
existingTextObj.height = existingTextObj.size * 1.2;
selectedObject = existingTextObj;
} else {
// Remove if empty
const idx = pageObjects.indexOf(existingTextObj);
if (idx !== -1) pageObjects.splice(idx, 1);
selectedObject = null;
}
redrawPageObjects();
} else if (text) {
drawCtx.font = `bold ${textSize}px Inter, sans-serif`;
const textWidth = drawCtx.measureText(text).width;
const newObj = {
id: Date.now(),
type: 'text',
text: text,
x: initialX,
y: initialY,
width: textWidth,
height: textSize * 1.2,
color: currentColor,
size: textSize
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
}
};
inlineInput.onkeydown = (eInput) => {
if (eInput.key === 'Enter') {
eInput.preventDefault();
commitInlineText();
} else if (eInput.key === 'Escape') {
eInput.preventDefault();
isCommitted = true;
if (inlineInput) inlineInput.remove();
redrawPageObjects();
}
};
inlineInput.onblur = () => {
setTimeout(commitInlineText, 100);
};
};
// Bind double click to re-edit existing text objects
drawCanvas.ondblclick = (eDbl) => {
eDbl.preventDefault();
eDbl.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const dblX = eDbl.clientX - rect.left;
const dblY = eDbl.clientY - rect.top;
const hitObj = hitTestObject(dblX, dblY);
if (hitObj && hitObj.type === 'text') {
openInlineTextEditor(eDbl.clientX, eDbl.clientY, dblX, dblY, hitObj);
}
};
if (currentTool === 'text') {
openInlineTextEditor(e.clientX, e.clientY, x, y);
return;
}
if (currentTool === 'shape') {
const shapeTypeEl = document.getElementById('pdf-shape-type');
const fillColorEl = document.getElementById('pdf-shape-fill-color');
const borderColorEl = document.getElementById('pdf-shape-border-color');
const borderWidthEl = document.getElementById('pdf-shape-border-width');
const shapeType = shapeTypeEl ? shapeTypeEl.value : 'rectangle';
const fillColor = fillColorEl ? fillColorEl.value : '#3b82f6';
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;
}
// Interactive click-and-drag for rectangle, circle / ellipse, etc.
const newShapeObj = {
id: Date.now(),
type: 'shape',
shapeType: shapeType,
startX: x,
startY: y,
x: x,
y: y,
width: 0,
height: 0,
fillColor: fillColor,
borderColor: borderColor,
borderWidth: borderWidth
};
pageObjects.push(newShapeObj);
selectedObject = newShapeObj;
isResizingObj = true;
resizeHandleIndex = 99; // Dynamic 2D shape creation drag
redrawPageObjects();
return;
}
// Drawing mode (pencil / brush / highlight)
isDrawingStroke = true;
currentStrokePoints = [{ x, y }];
const opacity = (currentTool === 'highlight' || currentBrushType === 'highlight')
? currentHighlightOpacity
: (currentTool === 'draw' ? currentHighlightOpacity : 1.0);
const size = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const newStrokeObj = {
id: Date.now(),
type: 'stroke',
points: currentStrokePoints,
x: x,
y: y,
width: size,
height: size,
color: currentColor,
size: size,
opacity: opacity,
brushType: currentBrushType
};
pageObjects.push(newStrokeObj);
// Keep selectedObject null during active drawing so no blue bounding box displays while drawing
selectedObject = null;
};
drawCanvas.onmousemove = (e) => {
const rect = drawCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
mousePos = { x, y };
if (isResizingObj && selectedObject) {
e.preventDefault();
const obj = selectedObject;
const oldX = obj.x;
const oldY = obj.y;
const oldW = obj.width || 1;
const oldH = obj.height || 1;
if (obj.type === 'shape' && resizeHandleIndex === 99) {
// Dynamic creation box for rectangle, ellipse, etc.
const startX = obj.startX !== undefined ? obj.startX : obj.x;
const startY = obj.startY !== undefined ? obj.startY : obj.y;
obj.x = Math.min(startX, x);
obj.y = Math.min(startY, y);
obj.width = Math.abs(x - startX);
obj.height = Math.abs(y - startY);
redrawPageObjects();
return;
}
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);
obj.y = Math.min(y, oldBottom - 10);
obj.width = oldRight - obj.x;
obj.height = oldBottom - obj.y;
} else if (resizeHandleIndex === 2) { // Top-Right
const oldLeft = obj.x;
const oldBottom = obj.y + obj.height;
obj.y = Math.min(y, oldBottom - 10);
obj.width = Math.max(10, x - oldLeft);
obj.height = oldBottom - obj.y;
} else if (resizeHandleIndex === 4) { // Bottom-Right
obj.width = Math.max(10, x - obj.x);
obj.height = Math.max(10, y - obj.y);
} else if (resizeHandleIndex === 6) { // Bottom-Left
const oldRight = obj.x + obj.width;
obj.x = Math.min(x, oldRight - 10);
obj.width = oldRight - obj.x;
obj.height = Math.max(10, y - obj.y);
} else if (resizeHandleIndex === 1) { // Top-Center
const oldBottom = obj.y + obj.height;
obj.y = Math.min(y, oldBottom - 10);
obj.height = oldBottom - obj.y;
} else if (resizeHandleIndex === 3) { // Middle-Right
obj.width = Math.max(10, x - obj.x);
} else if (resizeHandleIndex === 5) { // Bottom-Center
obj.height = Math.max(10, y - obj.y);
} else if (resizeHandleIndex === 7) { // Middle-Left
const oldRight = obj.x + obj.width;
obj.x = Math.min(x, oldRight - 10);
obj.width = oldRight - obj.x;
}
if (obj.type === 'stroke' && obj.points) {
const scaleX = obj.width / oldW;
const scaleY = obj.height / oldH;
obj.points.forEach(p => {
p.x = obj.x + (p.x - oldX) * scaleX;
p.y = obj.y + (p.y - oldY) * scaleY;
});
} else if (obj.type === 'text') {
const scaleY = obj.height / oldH;
obj.size = Math.max(10, Math.round((obj.size || 16) * scaleY));
drawCtx.font = `bold ${obj.size}px Inter, sans-serif`;
obj.width = drawCtx.measureText(obj.text).width;
obj.height = obj.size * 1.2;
}
redrawPageObjects();
return;
}
if (isDraggingObj && selectedObject) {
e.preventDefault();
const newX = x - dragOffsetX;
const newY = y - dragOffsetY;
const dx = newX - selectedObject.x;
const dy = newY - selectedObject.y;
selectedObject.x = newX;
selectedObject.y = newY;
if (selectedObject.type === 'stroke' && selectedObject.points) {
selectedObject.points.forEach(p => {
p.x += dx;
p.y += dy;
});
}
redrawPageObjects();
return;
}
// Update cursor on hover over object or handle
if (selectedObject) {
const hIdx = hitTestHandle(selectedObject, x, y);
if (hIdx !== -1) {
drawCanvas.style.cursor = 'nwse-resize';
return;
}
}
const hoverObj = hitTestObject(x, y);
if (hoverObj) {
drawCanvas.style.cursor = 'move';
} else if (currentTool === 'select') {
drawCanvas.style.cursor = 'default';
} else {
// Custom subtle dot cursor so the outer stroke-size circle is the main visual indicator
drawCanvas.style.cursor = 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'8\' viewBox=\'0 0 8 8\'><circle cx=\'4\' cy=\'4\' r=\'1.5\' fill=\'%23ffffff\' stroke=\'%23000000\' stroke-width=\'0.8\'/></svg>") 4 4, 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) {
e.preventDefault();
e.stopPropagation();
currentStrokePoints.push({ x, y });
// Update stroke bounding box
if (pageObjects.length > 0) {
const activeStrokeObj = pageObjects[pageObjects.length - 1];
if (activeStrokeObj && activeStrokeObj.type === 'stroke') {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
currentStrokePoints.forEach(p => {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
});
activeStrokeObj.x = minX;
activeStrokeObj.y = minY;
activeStrokeObj.width = maxX - minX;
activeStrokeObj.height = maxY - minY;
}
}
}
redrawPageObjects();
};
const stopDrawing = (e) => {
if (isResizingObj && selectedObject && selectedObject.type === 'shape') {
if (resizeHandleIndex === 99) {
if (selectedObject.width < 5 && selectedObject.height < 5) {
const defaultW = 120;
const defaultH = 80;
const startX = selectedObject.startX !== undefined ? selectedObject.startX : selectedObject.x;
const startY = selectedObject.startY !== undefined ? selectedObject.startY : selectedObject.y;
selectedObject.x = startX - defaultW / 2;
selectedObject.y = startY - defaultH / 2;
selectedObject.width = defaultW;
selectedObject.height = defaultH;
}
} else if (selectedObject.shapeType === 'line' || selectedObject.shapeType === 'arrow') {
if (Math.hypot(selectedObject.width, selectedObject.height) < 5) {
selectedObject.width = 120;
selectedObject.height = 0;
}
}
}
if (isDrawingStroke && pageObjects.length > 0) {
const activeObj = pageObjects[pageObjects.length - 1];
if (activeObj && activeObj.type === 'stroke') {
if (!activeObj.points || activeObj.points.length <= 1) {
// Single click without dragging: discard stroke
pageObjects.pop();
redrawPageObjects();
}
}
}
isDrawingStroke = false;
isDraggingObj = false;
isResizingObj = false;
resizeHandleIndex = -1;
redrawPageObjects();
};
drawCanvas.onmouseup = stopDrawing;
drawCanvas.onmouseleave = (e) => {
mousePos = null;
stopDrawing(e);
redrawPageObjects();
};
} catch (pErr) {
console.warn(`Failed rendering PDF page ${num}`, pErr);
}
}
// Clear button handler for current page
if (toolClearBtn) {
toolClearBtn.onclick = (e) => {
e.stopPropagation();
const pageContainers = wrapper.querySelectorAll('.pdf-page-container');
let activePageNum = 1;
const wrapperTop = wrapper.scrollTop;
pageContainers.forEach((container) => {
if (container.offsetTop - wrapper.offsetTop <= wrapperTop + 150) {
activePageNum = parseInt(container.dataset.pageNum, 10);
}
});
const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${activePageNum}`);
if (drawCanvas) {
if (drawCanvas.pageObjects) {
drawCanvas.pageObjects.length = 0;
}
const ctx = drawCanvas.getContext('2d');
ctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
Toast.info(`Pagina ${activePageNum} pulita.`);
}
};
}
// Save Edited PDF using pdf-lib and html canvas flattening
if (saveEditedBtn) {
saveEditedBtn.onclick = (e) => {
e.stopPropagation();
savePdfDocument();
};
}
// Scroll listener to update page counter in toolbar
wrapper.addEventListener('scroll', () => {
const pageContainers = wrapper.querySelectorAll('.pdf-page-container');
let currentVisiblePage = 1;
const wrapperTop = wrapper.scrollTop;
pageContainers.forEach((container) => {
if (container.offsetTop - wrapper.offsetTop <= wrapperTop + 150) {
currentVisiblePage = parseInt(container.dataset.pageNum, 10);
}
});
if (pageNumEl) {
pageNumEl.textContent = `Pagina ${currentVisiblePage} / ${totalPages}`;
}
});
} catch (err) {
console.error('Failed to load PDF via PDF.js', err);
const loadingEl = document.getElementById('pdf-loading');
if (loadingEl) {
loadingEl.innerHTML = `<span style="color:var(--danger);">Errore caricamento PDF: ${App.escapeHtml(err.message)}</span>`;
}
}
},
initImageEditor(url, filename) {
const baseCanvas = document.getElementById('image-base-canvas');
const drawCanvas = document.getElementById('image-draw-canvas');
const wrapper = document.getElementById('image-canvas-wrapper');
if (!baseCanvas || !drawCanvas || !wrapper) return;
const baseCtx = baseCanvas.getContext('2d');
const drawCtx = drawCanvas.getContext('2d');
const loadedImg = new Image();
loadedImg.crossOrigin = 'anonymous';
let brightness = 100;
let contrast = 100;
let saturate = 100;
let rotation = 0;
let isGrayscale = false;
let currentTool = 'draw';
let currentColor = '#ef4444';
let currentHighlightOpacity = 0.15;
let currentBrushType = 'pencil';
let currentBrushSize = 3;
let activeSignature = null;
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 pageObjects = [];
drawCanvas.pageObjects = pageObjects;
let selectedObject = null;
let isDraggingObj = false;
let isResizingObj = false;
let resizeHandleIndex = -1;
let dragOffsetX = 0;
let dragOffsetY = 0;
let isDrawingStroke = false;
let currentStrokePoints = [];
const renderBaseImage = () => {
const w = baseCanvas.width;
const h = baseCanvas.height;
baseCtx.clearRect(0, 0, w, h);
baseCtx.save();
let filterStr = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturate}%)`;
if (isGrayscale) filterStr += ' grayscale(100%)';
baseCtx.filter = filterStr;
if (rotation !== 0) {
baseCtx.translate(w / 2, h / 2);
baseCtx.rotate((rotation * Math.PI) / 180);
if (rotation === 90 || rotation === 270) {
baseCtx.translate(-h / 2, -w / 2);
baseCtx.drawImage(loadedImg, 0, 0, h, w);
} else {
baseCtx.translate(-w / 2, -h / 2);
baseCtx.drawImage(loadedImg, 0, 0, w, h);
}
} else {
baseCtx.drawImage(loadedImg, 0, 0, w, h);
}
baseCtx.restore();
};
let mousePos = null;
const redrawPageObjects = () => {
drawCtx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
pageObjects.forEach(obj => {
drawCtx.save();
if (obj.type === 'stroke') {
if (obj.points && obj.points.length > 0) {
drawCtx.beginPath();
drawCtx.moveTo(obj.points[0].x, obj.points[0].y);
obj.points.forEach(p => drawCtx.lineTo(p.x, p.y));
drawCtx.strokeStyle = obj.color;
drawCtx.lineWidth = obj.size;
if (obj.brushType === 'highlight' || obj.type === 'highlight') {
drawCtx.globalCompositeOperation = 'multiply';
drawCtx.globalAlpha = obj.opacity !== undefined ? obj.opacity : 0.15;
drawCtx.lineCap = 'square';
} else {
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.globalAlpha = obj.opacity !== undefined ? obj.opacity : 1.0;
drawCtx.lineCap = 'round';
}
drawCtx.lineJoin = 'round';
drawCtx.stroke();
}
} else if (obj.type === 'text') {
drawCtx.font = `bold ${obj.size || 16}px Inter, sans-serif`;
drawCtx.fillStyle = obj.color;
drawCtx.globalAlpha = 1.0;
drawCtx.fillText(obj.text, obj.x, obj.y + (obj.size || 16));
} else if (obj.type === 'image') {
drawCtx.globalAlpha = 1.0;
if (obj.img) {
drawCtx.drawImage(obj.img, obj.x, obj.y, obj.width, obj.height);
}
} else if (obj.type === 'shape') {
drawCtx.globalAlpha = 1.0;
drawCtx.beginPath();
if (obj.shapeType === 'circle') {
const rx = obj.width / 2;
const ry = obj.height / 2;
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;
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();
}
}
}
drawCtx.restore();
if (selectedObject === obj) {
drawCtx.save();
drawCtx.strokeStyle = '#3b82f6';
drawCtx.lineWidth = 1.5;
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
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([]);
}
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, 5, 0, Math.PI * 2);
drawCtx.fill();
drawCtx.stroke();
});
drawCtx.restore();
}
});
// Draw brush size circle indicator under mouse cursor when drawing/highlighting
if (mousePos && (currentTool === 'draw' || currentTool === 'highlight')) {
const currentSize = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const radius = Math.max(2, currentSize / 2);
drawCtx.save();
drawCtx.globalCompositeOperation = 'source-over';
drawCtx.beginPath();
drawCtx.arc(mousePos.x, mousePos.y, radius, 0, Math.PI * 2);
drawCtx.strokeStyle = 'rgba(0, 0, 0, 0.7)';
drawCtx.lineWidth = 1.5;
drawCtx.stroke();
drawCtx.beginPath();
drawCtx.arc(mousePos.x, mousePos.y, radius, 0, Math.PI * 2);
drawCtx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
drawCtx.lineWidth = 1;
drawCtx.stroke();
drawCtx.restore();
}
};
const getObjectHandles = (obj) => {
const x = obj.x; const y = obj.y; const w = obj.width; const h = obj.height;
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
return [
{ x: x, y: y },
{ x: x + w / 2, y: y + h / 2 },
{ x: x + w, y: y + h }
];
}
return [
{ x: x, y: y }, { x: x + w / 2, y: y }, { x: x + w, y: y },
{ x: x + w, y: y + h / 2 }, { x: x + w, y: y + h },
{ x: x + w / 2, y: y + h }, { x: x, y: y + h }, { x: x, y: y + h / 2 }
];
};
const hitTestObject = (x, y) => {
for (let i = pageObjects.length - 1; i >= 0; i--) {
const obj = pageObjects[i];
if (x >= obj.x - 6 && x <= obj.x + obj.width + 6 && y >= obj.y - 6 && y <= obj.y + obj.height + 6) {
return obj;
}
}
return null;
};
const hitTestHandle = (obj, x, y) => {
if (!obj) return -1;
const handles = getObjectHandles(obj);
for (let i = 0; i < handles.length; i++) {
const h = handles[i];
if (Math.hypot(x - h.x, y - h.y) <= 7) return i;
}
return -1;
};
loadedImg.onload = () => {
const containerW = wrapper.clientWidth - 32 || 800;
const containerH = wrapper.clientHeight - 32 || 600;
let targetW = loadedImg.naturalWidth || loadedImg.width || 800;
let targetH = loadedImg.naturalHeight || loadedImg.height || 600;
const scale = Math.min(containerW / targetW, containerH / targetH, 1.0);
targetW = Math.max(300, Math.round(targetW * scale));
targetH = Math.max(200, Math.round(targetH * scale));
baseCanvas.width = targetW;
baseCanvas.height = targetH;
drawCanvas.width = targetW;
drawCanvas.height = targetH;
renderBaseImage();
redrawPageObjects();
};
loadedImg.src = url;
// Image Adjustment inputs
const bInput = document.getElementById('img-adj-brightness');
const cInput = document.getElementById('img-adj-contrast');
const sInput = document.getElementById('img-adj-saturate');
const btnRotate = document.getElementById('img-btn-rotate');
const btnGrayscale = document.getElementById('img-btn-grayscale');
const btnResetAdj = document.getElementById('img-btn-reset-adj');
if (bInput) bInput.oninput = (e) => { brightness = e.target.value; renderBaseImage(); };
if (cInput) cInput.oninput = (e) => { contrast = e.target.value; renderBaseImage(); };
if (sInput) sInput.oninput = (e) => { saturate = e.target.value; renderBaseImage(); };
if (btnRotate) btnRotate.onclick = (e) => { e.stopPropagation(); rotation = (rotation + 90) % 360; renderBaseImage(); };
if (btnGrayscale) btnGrayscale.onclick = (e) => {
e.stopPropagation();
isGrayscale = !isGrayscale;
btnGrayscale.style.background = isGrayscale ? 'var(--accent-primary)' : 'transparent';
renderBaseImage();
};
if (btnResetAdj) btnResetAdj.onclick = (e) => {
e.stopPropagation();
brightness = 100; contrast = 100; saturate = 100; rotation = 0; isGrayscale = false;
if (bInput) bInput.value = 100;
if (cInput) cInput.value = 100;
if (sInput) sInput.value = 100;
if (btnGrayscale) btnGrayscale.style.background = 'transparent';
renderBaseImage();
};
// Tool switching
const toolSelectBtn = document.getElementById('img-tool-select');
const toolDrawBtn = document.getElementById('img-tool-draw');
const toolTextBtn = document.getElementById('img-tool-text');
const toolHighlightBtn = document.getElementById('img-tool-highlight');
const toolShapeBtn = document.getElementById('img-tool-shape');
const toolSigBtn = document.getElementById('img-tool-signature');
const shapeControls = document.getElementById('img-shape-controls');
const highlightControls = document.getElementById('img-highlight-controls');
const highlightOpacityInput = document.getElementById('img-highlight-opacity');
const highlightOpacityVal = document.getElementById('img-highlight-opacity-val');
const colorPicker = document.getElementById('img-color-picker');
const toolClearBtn = document.getElementById('img-tool-clear');
const saveEditedBtn = document.getElementById('img-save-edited');
if (highlightOpacityInput && highlightOpacityVal) {
highlightOpacityInput.oninput = (e) => {
currentHighlightOpacity = parseFloat(e.target.value);
highlightOpacityVal.textContent = `${Math.round(currentHighlightOpacity * 100)}%`;
};
}
const setActiveTool = (tool, activeBtn) => {
currentTool = tool;
const allToolBtns = [toolSelectBtn, toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, toolSigBtn];
allToolBtns.forEach(b => {
if (b) {
b.style.background = 'transparent';
b.classList.remove('active');
}
});
if (activeBtn) {
activeBtn.style.background = 'var(--accent-primary)';
activeBtn.classList.add('active');
}
if (shapeControls) {
shapeControls.style.display = tool === 'shape' ? 'flex' : 'none';
}
if (highlightControls) {
highlightControls.style.display = (tool === 'draw' || tool === 'highlight') ? 'flex' : 'none';
}
drawCanvas.style.pointerEvents = tool === 'select' ? 'none' : 'auto';
};
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 (toolShapeBtn) toolShapeBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('shape', toolShapeBtn); };
if (toolSigBtn) toolSigBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('signature', toolSigBtn); };
if (colorPicker) {
colorPicker.onchange = (e) => {
currentColor = e.target.value;
if (selectedObject) {
if (selectedObject.type === 'stroke') selectedObject.color = currentColor;
else if (selectedObject.type === 'text') selectedObject.color = currentColor;
redrawPageObjects();
}
};
}
if (toolClearBtn) {
toolClearBtn.onclick = (e) => {
e.stopPropagation();
pageObjects.length = 0;
selectedObject = null;
redrawPageObjects();
Toast.info('Annotazioni immagine pulite');
};
}
if (saveEditedBtn) {
saveEditedBtn.onclick = (e) => {
e.stopPropagation();
this.saveImageDocument(filename);
};
}
// Mouse Events on drawCanvas
drawCanvas.onmousedown = (e) => {
if (e.button !== 0) return;
const existingPopup = document.getElementById('img-context-menu-popup') || document.getElementById('pdf-context-menu-popup');
if (existingPopup) existingPopup.remove();
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (selectedObject) {
const hIdx = hitTestHandle(selectedObject, x, y);
if (hIdx !== -1) {
isResizingObj = true;
resizeHandleIndex = hIdx;
return;
}
}
const hitObj = hitTestObject(x, y);
if (hitObj) {
selectedObject = hitObj;
isDraggingObj = true;
dragOffsetX = x - hitObj.x;
dragOffsetY = y - hitObj.y;
redrawPageObjects();
return;
}
if (selectedObject) {
selectedObject = null;
redrawPageObjects();
}
if (currentTool === 'select') 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();
};
img.src = activeSignature.image_data;
} else {
Toast.warning('Nessuna firma selezionata.');
}
return;
}
if (currentTool === 'shape') {
const shapeTypeEl = document.getElementById('img-shape-type');
const fillColorEl = document.getElementById('img-shape-fill-color');
const borderColorEl = document.getElementById('img-shape-border-color');
const borderWidthEl = document.getElementById('img-shape-border-width');
const shapeType = shapeTypeEl ? shapeTypeEl.value : 'rectangle';
const fillColor = fillColorEl ? fillColorEl.value : '#3b82f6';
const borderColor = borderColorEl ? borderColorEl.value : '#1e3a8a';
const borderWidth = borderWidthEl ? parseInt(borderWidthEl.value, 10) : 3;
if (shapeType === 'line' || shapeType === 'arrow') {
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;
redrawPageObjects();
return;
}
const newShapeObj = {
id: Date.now(),
type: 'shape',
shapeType: shapeType,
startX: x, startY: y, x: x, y: y, width: 0, height: 0,
fillColor: fillColor, borderColor: borderColor, borderWidth: borderWidth
};
pageObjects.push(newShapeObj);
selectedObject = newShapeObj;
isResizingObj = true;
resizeHandleIndex = 99;
redrawPageObjects();
return;
}
isDrawingStroke = true;
currentStrokePoints = [{ x, y }];
const opacity = (currentTool === 'highlight' || currentBrushType === 'highlight')
? currentHighlightOpacity
: (currentTool === 'draw' ? currentHighlightOpacity : 1.0);
const size = (currentTool === 'highlight' || currentBrushType === 'highlight') ? (currentBrushSize || 20) : (currentBrushType === 'brush' ? (currentBrushSize || 8) : (currentBrushSize || 3));
const newStrokeObj = {
id: Date.now(),
type: 'stroke',
points: currentStrokePoints,
x: x, y: y, width: size, height: size,
color: currentColor, size: size, opacity: opacity, brushType: currentBrushType
};
pageObjects.push(newStrokeObj);
selectedObject = null;
};
drawCanvas.onmousemove = (e) => {
const rect = drawCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
mousePos = { x, y };
if (isResizingObj && selectedObject) {
e.preventDefault();
const obj = selectedObject;
if (obj.type === 'shape' && resizeHandleIndex === 99) {
const startX = obj.startX !== undefined ? obj.startX : obj.x;
const startY = obj.startY !== undefined ? obj.startY : obj.y;
obj.x = Math.min(startX, x);
obj.y = Math.min(startY, y);
obj.width = Math.abs(x - startX);
obj.height = Math.abs(y - startY);
redrawPageObjects();
return;
}
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
if (resizeHandleIndex === 0) {
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) {
obj.width = x - obj.x; obj.height = y - obj.y;
}
} else if (resizeHandleIndex === 4) {
obj.width = Math.max(10, x - obj.x);
obj.height = Math.max(10, y - obj.y);
}
redrawPageObjects();
return;
}
if (isDraggingObj && selectedObject) {
e.preventDefault();
selectedObject.x = x - dragOffsetX;
selectedObject.y = y - dragOffsetY;
redrawPageObjects();
return;
}
if (isDrawingStroke) {
e.preventDefault();
currentStrokePoints.push({ x, y });
}
if (selectedObject) {
const hIdx = hitTestHandle(selectedObject, x, y);
if (hIdx !== -1) {
drawCanvas.style.cursor = 'nwse-resize';
redrawPageObjects();
return;
}
}
const hoverObj = hitTestObject(x, y);
if (hoverObj) {
drawCanvas.style.cursor = 'move';
} else if (currentTool === 'select') {
drawCanvas.style.cursor = 'default';
} else {
// Custom subtle dot cursor so the outer stroke-size circle is the main visual indicator
drawCanvas.style.cursor = 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'8\' viewBox=\'0 0 8 8\'><circle cx=\'4\' cy=\'4\' r=\'1.5\' fill=\'%23ffffff\' stroke=\'%23000000\' stroke-width=\'0.8\'/></svg>") 4 4, crosshair';
}
redrawPageObjects();
};
const stopDrawing = () => {
if (isResizingObj && selectedObject && selectedObject.type === 'shape') {
if (resizeHandleIndex === 99) {
if (selectedObject.width < 5 && selectedObject.height < 5) {
const defaultW = 120; const defaultH = 80;
const startX = selectedObject.startX !== undefined ? selectedObject.startX : selectedObject.x;
const startY = selectedObject.startY !== undefined ? selectedObject.startY : selectedObject.y;
selectedObject.x = startX - defaultW / 2;
selectedObject.y = startY - defaultH / 2;
selectedObject.width = defaultW;
selectedObject.height = defaultH;
}
} else if (selectedObject.shapeType === 'line' || selectedObject.shapeType === 'arrow') {
if (Math.hypot(selectedObject.width, selectedObject.height) < 5) {
selectedObject.width = 120; selectedObject.height = 0;
}
}
}
isDrawingStroke = false;
isDraggingObj = false;
isResizingObj = false;
resizeHandleIndex = -1;
redrawPageObjects();
};
drawCanvas.onmouseup = stopDrawing;
drawCanvas.onmouseleave = () => {
mousePos = null;
stopDrawing();
};
// Ctrl + Right Click Context Menu Handler for Image Editor
drawCanvas.oncontextmenu = async (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
const rect = drawCanvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Fetch signatures and shapes from SQLite
let signatures = [];
let favoriteShapes = [];
try {
const [sigRes, shapeRes] = await Promise.all([
App.api('/api/config/user-signatures'),
App.api('/api/config/user-shapes')
]);
signatures = sigRes || [];
favoriteShapes = (shapeRes || []).filter(s => s.is_favorite);
} catch (_) { }
let menuOverlay = document.getElementById('img-context-menu-popup');
if (menuOverlay) menuOverlay.remove();
menuOverlay = document.createElement('div');
menuOverlay.id = 'img-context-menu-popup';
menuOverlay.style.cssText = `
position: fixed;
left: ${e.clientX}px;
top: ${e.clientY}px;
z-index: 20000;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
color: #ffffff;
font-size: 0.8rem;
min-width: 260px;
user-select: none;
`;
menuOverlay.innerHTML = `
<div style="font-weight: 700; margin-bottom: 8px; 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>⚙️ Strumenti Rapidi Immagine</span>
<span style="cursor:pointer; opacity:0.7;" id="ctx-close">✕</span>
</div>
<div style="margin-bottom: 10px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:4px;">Tipo di Strumento</label>
<div style="display:flex; gap:4px; flex-wrap:wrap;">
<button class="ctx-btn ${currentTool === 'draw' && currentBrushType === 'pencil' ? 'active' : ''}" data-type="pencil" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'draw' && currentBrushType === 'pencil' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">✏️ Matita</button>
<button class="ctx-btn ${currentTool === 'draw' && currentBrushType === 'brush' ? 'active' : ''}" data-type="brush" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'draw' && currentBrushType === 'brush' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">🖌️ Pennello</button>
<button class="ctx-btn ${currentTool === 'highlight' ? 'active' : ''}" data-type="highlight" style="flex:1; min-width:65px; padding:4px; font-size:0.75rem; background:${currentTool === 'highlight' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">🖍️ Evidenzia</button>
<button class="ctx-btn ${currentTool === 'shape' ? 'active' : ''}" data-type="shape" style="flex:1; min-width:60px; padding:4px; font-size:0.75rem; background:${currentTool === 'shape' ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; color:#fff; border:none; border-radius:4px; cursor:pointer;">📐 Forme</button>
</div>
</div>
<div style="margin-bottom: 10px; display:flex; gap:8px; align-items:center;">
<div style="flex:1;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:2px;">
<label style="font-size:0.75rem; color:#94a3b8;">Dimensione tratto</label>
<span id="ctx-brush-val-badge" style="font-size:0.72rem; font-family:monospace; color:#38bdf8; font-weight:700;">${currentBrushSize}px</span>
</div>
<div style="display:flex; align-items:center; gap:6px;">
<input type="range" id="ctx-brush-size" min="1" max="40" value="${currentBrushSize}" style="flex:1; cursor:pointer;" />
<div id="ctx-brush-preview-box" title="Anteprima dimensione reale" style="width:26px; height:26px; border-radius:4px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.2); display:flex; align-items:center; justify-content:center; flex-shrink:0;">
<div id="ctx-brush-preview-circle" style="width:${Math.max(2, currentBrushSize)}px; height:${Math.max(2, currentBrushSize)}px; max-width:22px; max-height:22px; border-radius:50%; background:${currentColor}; transition:all 0.1s ease;"></div>
</div>
</div>
</div>
<div>
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:2px;">Colore</label>
<input type="color" id="ctx-color" value="${currentColor}" style="width:26px; height:26px; border:none; border-radius:50%; cursor:pointer; background:none;" />
</div>
</div>
<!-- Favorite Shapes Section -->
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px; margin-bottom: 8px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:6px;">📐 Forme Preferite Salvate</label>
${favoriteShapes.length > 0 ? `
<div style="max-height: 100px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
${favoriteShapes.map(sh => `
<button class="ctx-shape-btn" data-id="${sh.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<span style="display:inline-block; width:16px; height:12px; border-radius:${sh.shape_type === 'circle' ? '50%' : '2px'}; background:${sh.fill_color === 'transparent' ? 'transparent' : sh.fill_color}; border:${Math.min(sh.border_width, 3)}px solid ${sh.border_color}; box-sizing:border-box;"></span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(sh.name)}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic;">Nessuna forma preferita salvata.</div>
`}
</div>
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px;">
<label style="font-size:0.75rem; color:#94a3b8; display:block; margin-bottom:6px;">✍️ Inserisci Firma / Immagine Utente</label>
${signatures.length > 0 ? `
<div style="max-height: 120px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;">
${signatures.map(s => `
<button class="ctx-sig-btn" data-id="${s.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); color:#fff; padding:4px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
<img src="${s.image_data}" style="width:28px; height:20px; object-fit:contain; background:#fff; border-radius:2px; padding:1px;" />
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(s.name)}</span>
</button>
`).join('')}
</div>
` : `
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic;">Nessuna firma salvata in SQLite.</div>
`}
</div>
`;
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;
menuOverlay.querySelectorAll('.ctx-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const type = btn.dataset.type;
menuOverlay.querySelectorAll('.ctx-btn').forEach(b => {
b.style.background = 'rgba(255,255,255,0.1)';
b.classList.remove('active');
});
btn.style.background = 'var(--accent-primary)';
btn.classList.add('active');
if (type === 'pencil') {
currentBrushType = 'pencil';
currentBrushSize = 3;
setActiveTool('draw', toolDrawBtn);
} else if (type === 'brush') {
currentBrushType = 'brush';
currentBrushSize = 8;
setActiveTool('draw', toolDrawBtn);
} else if (type === 'highlight') {
currentBrushType = 'highlight';
currentBrushSize = 20;
setActiveTool('highlight', toolHighlightBtn);
} else if (type === 'shape') {
setActiveTool('shape', toolShapeBtn);
}
const valBadge = menuOverlay.querySelector('#ctx-brush-val-badge');
if (valBadge) valBadge.textContent = `${currentBrushSize}px`;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) {
pCircle.style.width = `${Math.max(2, currentBrushSize)}px`;
pCircle.style.height = `${Math.max(2, currentBrushSize)}px`;
}
if (sizeInput) sizeInput.value = currentBrushSize;
};
});
const sizeInput = menuOverlay.querySelector('#ctx-brush-size');
if (sizeInput) {
sizeInput.oninput = (eVal) => {
eVal.stopPropagation();
const val = parseInt(eVal.target.value, 10);
currentBrushSize = val;
const valBadge = menuOverlay.querySelector('#ctx-brush-val-badge');
if (valBadge) valBadge.textContent = `${val}px`;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) {
pCircle.style.width = `${Math.max(2, val)}px`;
pCircle.style.height = `${Math.max(2, val)}px`;
}
const shapeBorderWidthEl = document.getElementById('img-shape-border-width');
if (shapeBorderWidthEl) {
shapeBorderWidthEl.value = val;
}
if (selectedObject && selectedObject.type === 'shape') {
selectedObject.borderWidth = val;
redrawPageObjects();
}
};
}
const ctxColorPicker = menuOverlay.querySelector('#ctx-color');
if (ctxColorPicker) {
ctxColorPicker.onchange = (eVal) => {
eVal.stopPropagation();
currentColor = eVal.target.value;
const pCircle = menuOverlay.querySelector('#ctx-brush-preview-circle');
if (pCircle) pCircle.style.background = currentColor;
if (colorPicker) colorPicker.value = currentColor;
};
}
menuOverlay.querySelectorAll('.ctx-shape-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const shId = btn.dataset.id;
const sh = favoriteShapes.find(s => s.id == shId);
if (sh) {
const defaultW = 130;
const defaultH = 85;
const newObj = {
id: Date.now(),
type: 'shape',
shapeType: sh.shape_type,
x: clickX - defaultW / 2,
y: clickY - defaultH / 2,
width: defaultW,
height: defaultH,
fillColor: sh.fill_color,
borderColor: sh.border_color,
borderWidth: sh.border_width
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Forma preferita "${sh.name}" inserita!`);
closeCtx();
}
};
});
menuOverlay.querySelectorAll('.ctx-sig-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const sId = btn.dataset.id;
const s = signatures.find(sig => sig.id == sId);
if (s) {
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: clickX - targetWidth / 2,
y: clickY - targetHeight / 2,
width: targetWidth,
height: targetHeight
};
pageObjects.push(newObj);
selectedObject = newObj;
redrawPageObjects();
Toast.success(`Firma "${s.name}" apposta!`);
closeCtx();
};
img.src = s.image_data;
}
};
});
const handleOutsideClick = (eClick) => {
if (menuOverlay && !menuOverlay.contains(eClick.target)) {
closeCtx();
document.removeEventListener('mousedown', handleOutsideClick);
}
};
setTimeout(() => {
document.addEventListener('mousedown', handleOutsideClick);
}, 50);
}
};
},
getOutputFilename(originalName) {
let base = originalName || 'documento.pdf';
// Clean any existing timestamp prefix to avoid duplication
base = base.replace(/^\d{8}_\d{6}_/, '');
const now = new Date();
const yyyy = now.getFullYear();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const timestamp = `${yyyy}${mm}${dd}_${hh}${min}${ss}`;
return `${timestamp}_${base}`;
},
saveImageDocument(filename) {
const baseCanvas = document.getElementById('image-base-canvas');
const drawCanvas = document.getElementById('image-draw-canvas');
if (!baseCanvas || !drawCanvas) return;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = baseCanvas.width;
tempCanvas.height = baseCanvas.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(baseCanvas, 0, 0);
tempCtx.drawImage(drawCanvas, 0, 0);
let baseName = filename || 'immagine_modificata.png';
if (!baseName.toLowerCase().match(/\.(png|jpg|jpeg|webp)$/)) {
baseName += '.png';
}
const outName = this.getOutputFilename(baseName);
tempCanvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outName;
document.body.appendChild(a);
a.click();
setTimeout(() => {
a.remove();
URL.revokeObjectURL(url);
}, 200);
Toast.success(`Immagine modificata salvata come ${outName}`);
}, 'image/png');
},
showPdfTextModal(onSubmitCallback) {
let modal = document.getElementById('pdf-text-modal-overlay');
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = 'pdf-text-modal-overlay';
modal.style.cssText = `
position: fixed;
inset: 0;
z-index: 25000;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
backdrop-filter: blur(2px);
`;
modal.innerHTML = `
<div style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 20px; width: 380px; max-width: 90vw; box-shadow: var(--shadow-lg); color: var(--text-primary);">
<div style="font-weight: 700; font-size: 1rem; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between;">
<span>🔤 Inserisci Testo sul PDF</span>
<button id="pdf-modal-close-btn" style="background: none; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer;">✕</button>
</div>
<div style="margin-bottom: 16px;">
<input type="text" id="pdf-modal-text-input" class="form-input" placeholder="Scrivi il testo da aggiungere..." style="width: 100%; height: 38px; font-size: 0.9rem;" value="Nota" />
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<button id="pdf-modal-cancel-btn" class="btn btn-ghost btn-sm">Annulla</button>
<button id="pdf-modal-confirm-btn" class="btn btn-primary btn-sm">Inserisci Testo</button>
</div>
</div>
`;
document.body.appendChild(modal);
const inputEl = modal.querySelector('#pdf-modal-text-input');
const confirmBtn = modal.querySelector('#pdf-modal-confirm-btn');
const cancelBtn = modal.querySelector('#pdf-modal-cancel-btn');
const closeBtn = modal.querySelector('#pdf-modal-close-btn');
if (inputEl) {
inputEl.focus();
inputEl.select();
}
const closeModal = () => {
if (modal) modal.remove();
};
const submitText = () => {
const val = inputEl ? inputEl.value : '';
closeModal();
if (onSubmitCallback) onSubmitCallback(val);
};
confirmBtn.onclick = submitText;
cancelBtn.onclick = closeModal;
closeBtn.onclick = closeModal;
inputEl.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitText();
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal();
}
};
modal.onclick = (e) => {
if (e.target === modal) closeModal();
};
},
async render(id) {
this.ticketId = id;
this.noteAttachments = [];
this.htmlMode = localStorage.getItem('otrs_turbo_html_mode') === 'true';
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
try {
await App.ensureLookups();
const data = await App.api(`/api/tickets/${id}`);
// Route guard: verify user is still on this ticket's detail page
const currentHash = (window.location.hash || '').split('?')[0];
if (currentHash !== `#/tickets/${id}`) {
return;
}
const { ticket, articles, attachments } = data;
// No automatic tab creation here anymore
let groupsData = { asMaster: [], asMember: [] };
try {
groupsData = await App.api(`/api/groups/by-ticket/${id}`);
} catch (gErr) {
console.warn('Failed to load group associations for ticket', gErr);
}
const localISO = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
const totalTime = articles.reduce((sum, a) => {
const val = parseFloat(a.time_unit);
return sum + (isNaN(val) ? 0 : val);
}, 0);
// Display ticket number in the topbar with OTRS link if available
const titleEl = document.getElementById('page-title');
if (titleEl) {
if (data.otrsWebUrl) {
titleEl.innerHTML = `<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; display:inline-flex; align-items:center; gap:6px;" title="Apri in OTRS">Ticket #${ticket.tn || id} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;opacity:0.75;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg></a>`;
} else {
titleEl.innerHTML = `Ticket #${ticket.tn || id}`;
}
}
this.originalValues = {
ticket_state_id: ticket.ticket_state_id,
ticket_priority_id: ticket.ticket_priority_id,
queue_id: ticket.queue_id,
queue_name: ticket.queue_name,
user_id: ticket.user_id,
responsible_user_id: ticket.responsible_user_id,
type_id: ticket.type_id,
customer_id: ticket.customer_id,
customer_user_id: ticket.customer_user_id,
customer_first: ticket.customer_first,
customer_last: ticket.customer_last,
};
const hasEmailDraft = App.getDraft(id, 'email');
const emailBtnText = hasEmailDraft ? 'Continua mail' : 'Invia Email';
const ticketRealAttachments = (attachments || []).filter(att => {
const fname = (att.filename || '').toLowerCase();
return !fname.startsWith('file-1') && !fname.startsWith('file-2') && !fname.startsWith('file-3');
});
container.innerHTML = `
<a class="back-link" onclick="window.location.hash = App.lastListView || '#/tickets'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
<div class="ticket-detail">
<div class="ticket-detail-main">
<!-- Header -->
<div class="card">
<div class="ticket-header" style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px; flex-wrap:wrap;">
<div class="ticket-header-info" style="flex:1; min-width:280px;">
<div class="ticket-number" style="display:inline-flex; align-items:center; gap:6px;">
<span class="copy-ticket-btn" data-tn="${ticket.tn}" style="cursor: pointer; font-size: 0.85rem;" title="Copia numero ticket">📋</span>
#${ticket.tn}
-
</div>
<h2 class="ticket-detail-title" style="display:inline-flex; align-items:center; gap:6px; flex-wrap:wrap;">
<span class="copy-full-ticket-title" data-full="${ticket.tn} - ${App.escapeHtml(ticket.title || '(senza titolo)')}" style="cursor: pointer;" title="Clicca per copiare: ${ticket.tn} - ${App.escapeHtml(ticket.title || '')}">
${App.escapeHtml(ticket.title || '(senza titolo)')}
</span>
<button class="open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.openTab(${ticket.id}, '${ticket.tn}', this.dataset.title, true); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
<button class="assign-open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.assignAndOpenTab(${ticket.id}, '${ticket.tn}', this.dataset.title); event.stopPropagation();" title="Assegna a me ed apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 8px; background:var(--accent-primary); color:#fff; border:none; border-radius:var(--radius-full); font-size:11px; font-weight:600; font-family:inherit; margin-left:6px; cursor:pointer; gap:3px;">
👤+ Assegna & Scheda
</button>
${ticketRealAttachments.length > 0 ? `
<button class="header-open-attachments-btn" style="display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 8px; background:var(--bg-tertiary); color:var(--accent-primary); border:1px solid var(--border-subtle); border-radius:var(--radius-full); font-size:11px; font-weight:600; font-family:inherit; margin-left:6px; cursor:pointer; gap:4px;" title="Visualizza allegati ticket (${ticketRealAttachments.length})">
📎 ${ticketRealAttachments.length}
</button>
` : ''}
</h2>
<div class="ticket-meta-badges">
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
<span class="badge badge-priority" data-priority="${App.priorityIndex(ticket.priority_name)}">${ticket.priority_name}</span>
<span class="badge badge-queue">${ticket.queue_name}</span>
${ticket.lock_name === 'lock' ? '<span class="badge" style="background:var(--warning-bg);color:var(--warning);">🔒 Bloccato</span>' : ''}
</div>
</div>
<!-- Gruppi Ticket sul lato destro dell'intestazione in due righe -->
<div id="ticket-groups-panel" style="display:flex; flex-direction:column; align-items:flex-end; gap:6px; font-size:0.8rem;">
<div id="ticket-groups-list" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end;">
${groupsData.asMaster.length === 0 && groupsData.asMember.length === 0 ? `
<span style="font-size:0.78rem; color:var(--text-muted);">Nessun gruppo associato</span>
` : ''}
${groupsData.asMaster.map(g => `
<span style="font-size:0.8rem;">
<span style="color:var(--accent-primary); font-weight:bold;">👑 Master in:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</span>
`).join('')}
${groupsData.asMember.map(g => `
<span style="font-size:0.8rem;">
<span style="color:var(--text-secondary); font-weight:bold;">🔗 Membro di:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</span>
`).join('')}
</div>
<div style="display:flex; align-items:center; gap:4px; margin-top:2px;">
<select class="form-select" id="group-association-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0;">
<option value="">-- Seleziona --</option>
</select>
<button class="btn btn-primary btn-sm" id="btn-associate-to-group" style="height:28px; padding:0 8px; font-size:0.75rem;">+ Aggiungi</button>
</div>
</div>
</div>
</div>
<!-- Quick Edit -->
<div class="card">
<div class="card-title">Modifica Rapida</div>
<div class="quick-edit">
<div class="quick-edit-field">
<label class="quick-edit-label">Stato</label>
<select class="quick-edit-select" id="qe-state" data-field="ticket_state_id">
${(App.lookups.states || []).map(s =>
`<option value="${s.id}" ${s.id === ticket.ticket_state_id ? 'selected' : ''}>${s.name}</option>`
).join('')}
</select>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Priorità</label>
<select class="quick-edit-select" id="qe-priority" data-field="ticket_priority_id">
${(App.lookups.priorities || []).map(p =>
`<option value="${p.id}" ${p.id === ticket.ticket_priority_id ? 'selected' : ''}>${p.name}</option>`
).join('')}
</select>
</div>
<div class="quick-edit-field" style="position:relative;">
<label class="quick-edit-label">Coda</label>
<input type="text" class="quick-edit-select" id="qe-queue-search" placeholder="Cerca coda..." autocomplete="off" value="${App.escapeHtml(ticket.queue_name || '')}" style="background-image: none; cursor: text;" />
<input type="hidden" id="qe-queue" data-field="queue_id" value="${ticket.queue_id || ''}" />
<div id="qe-queue-suggestions" class="autocomplete-suggestions" style="display:none; min-width: 500px; width: max-content; max-width: 85vw; z-index: 1005;"></div>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Owner</label>
<select class="quick-edit-select" id="qe-owner" data-field="user_id">
${(App.lookups.users || []).map(u =>
`<option value="${u.id}" ${u.id === ticket.user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
).join('')}
</select>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Responsabile</label>
<select class="quick-edit-select" id="qe-responsible" data-field="responsible_user_id">
<option value="">—</option>
${(App.lookups.users || []).map(u =>
`<option value="${u.id}" ${u.id === ticket.responsible_user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
).join('')}
</select>
</div>
${(App.lookups.types || []).length > 0 ? `
<div class="quick-edit-field">
<label class="quick-edit-label">Tipo</label>
<select class="quick-edit-select" id="qe-type" data-field="type_id">
<option value="">—</option>
${App.lookups.types.map(t =>
`<option value="${t.id}" ${t.id === ticket.type_id ? 'selected' : ''}>${t.name}</option>`
).join('')}
</select>
</div>
` : ''}
<div class="quick-edit-field" style="position:relative;">
<label class="quick-edit-label">Utente Cliente</label>
<input type="text" class="form-input" id="qe-customer-search" placeholder="Cerca cliente..." autocomplete="off" value="${ticket.customer_first ? `${ticket.customer_first} ${ticket.customer_last}` : (ticket.customer_user_id || '')}" style="padding: 6px 12px; height: 32px; font-size: 0.85rem;" />
<input type="hidden" id="qe-customer-user-id" data-field="customer_user_id" value="${ticket.customer_user_id || ''}" />
<input type="hidden" id="qe-customer-id" data-field="customer_id" value="${ticket.customer_id || ''}" />
<div id="qe-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%;"></div>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Consuntivazione Tempo (min)</label>
<input type="number" step="any" min="0" class="form-input" id="note-time-units" placeholder="minuti" style="padding: 6px 12px; height: 32px; font-size: 0.85rem;" />
</div>
</div>
<!-- Integrated Note & Article Editor inside Modifica Rapida -->
<div style="border-top: 1px solid var(--border-subtle); margin-top: var(--space-lg); padding-top: var(--space-md);">
<div style="font-weight: 700; font-size: 0.85rem; color: var(--text-primary); margin-bottom: var(--space-sm); display: flex; align-items: center; justify-content: space-between;">
<span>📝 Aggiungi Nota / Messaggio Interno</span>
<span style="font-size: 0.75rem; font-weight: normal; color: var(--text-muted);">Opzionale se aggiorni solo i campi sopra</span>
</div>
<div style="margin-bottom: var(--space-sm);">
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto della nota (opzionale)" style="width: 100%; margin-bottom: 0; height: 32px; font-size: 0.85rem;" />
</div>
<div id="note-body-container" style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-sm);">
<div id="note-body-editor" style="min-height: 160px; font-family: inherit; font-size: 0.95rem; border: none; color: var(--text-primary);"></div>
</div>
<!-- Attachments List for note -->
<div class="upload-file-list" id="note-file-list" style="margin-bottom: var(--space-sm);"></div>
<div style="display:flex; gap:var(--space-md); justify-content:space-between; align-items:center; flex-wrap:wrap;">
<div style="display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
<div style="display:flex; align-items:center; gap:6px;">
<label for="note-time-units-bottom" style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Tempo (min):</label>
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units-bottom" placeholder="minuti" style="width:90px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.85rem;" />
</div>
<input type="file" id="note-attachments" multiple style="display:none;" />
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
</div>
<div style="display:flex; gap:var(--space-sm); align-items:center; flex-wrap:wrap;">
<label for="note-create-time" style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Data/Ora:</label>
<input type="datetime-local" class="note-subject-input" id="note-create-time" title="Data/ora nota (opzionale per retrodatazione)" style="width:185px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.82rem;" />
<button class="btn btn-ghost btn-sm" id="btn-open-email-compose" style="height:32px; display:flex; align-items:center; gap:var(--space-xs); border-color:var(--accent-secondary); color:var(--accent-secondary);"
data-ticket-id="${ticket.id}" data-ticket-tn="${ticket.tn}" data-ticket-title="${App.escapeHtml(ticket.title)}" data-customer-email="${App.escapeHtml(ticket.customer_email || '')}">
<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>
${emailBtnText}
</button>
<button class="btn btn-ghost btn-sm" id="qe-reset" style="height:32px;">Reset</button>
<button class="btn btn-primary btn-sm" id="qe-save" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
💾 Salva Modifiche / Invia Nota
</button>
</div>
</div>
</div>
</div>
<!-- Articles Timeline -->
<div>
<div class="article-header-wrapper">
<div class="card-title" style="margin-bottom:0;">Articoli & Note (${articles.length})</div>
<label class="html-toggle-container">
<input type="checkbox" class="html-toggle-input" id="html-toggle" ${this.htmlMode ? 'checked' : ''} />
<span>Visualizza HTML</span>
</label>
</div>
<div class="articles-timeline">
${articles.length > 0 ? articles.map(a => {
const hasHtml = a.has_html || (a.a_content_type || '').toLowerCase().includes('html') || /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(a.html_body || a.a_body || '');
let processedBody = (hasHtml && a.html_body) ? a.html_body : (a.a_body || '');
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
const usedAttachmentIds = new Set();
if (hasHtml && this.htmlMode) {
articleAttachments.forEach(att => {
let replaced = false;
if (att.content_id) {
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
if (cleanCid) {
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
if (regex.test(processedBody)) {
processedBody = processedBody.replace(regex, `/api/tickets/attachments/${att.id}`);
replaced = true;
}
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
if (regexFilename.test(processedBody)) {
processedBody = processedBody.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
replaced = true;
}
const regexSrc = new RegExp(`src=["']${escapedFilename}["']`, 'gi');
if (regexSrc.test(processedBody)) {
processedBody = processedBody.replace(regexSrc, `src="/api/tickets/attachments/${att.id}"`);
replaced = true;
}
const nameWithoutExt = att.filename.replace(/\.[^/.]+$/, '');
if (nameWithoutExt && nameWithoutExt.length > 3) {
const escapedBase = nameWithoutExt.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexBase = new RegExp(`cid:<?${escapedBase}[^"'>\\s]*>?`, 'gi');
if (regexBase.test(processedBody)) {
processedBody = processedBody.replace(regexBase, `/api/tickets/attachments/${att.id}`);
replaced = true;
}
}
}
if (att.disposition === 'inline' || replaced) {
usedAttachmentIds.add(att.id);
}
});
if (!processedBody.includes('<base')) {
if (processedBody.includes('<head>')) {
processedBody = processedBody.replace('<head>', '<head><base target="_blank">');
} else {
processedBody = `<base target="_blank">${processedBody}`;
}
}
}
const displayBody = (this.htmlMode && hasHtml)
? `<iframe id="article-iframe-${a.article_id}" srcdoc="${processedBody.replace(/"/g, '&quot;')}"
onload="try{this.style.height=(this.contentWindow.document.body.scrollHeight+30)+'px';}catch(e){}"
style="width:100%; border:none; background:#ffffff; color:#222222; border-radius:var(--radius-md); min-height:160px; padding:0; display:block;">
</iframe>`
: `<div class="article-body" style="white-space:pre-wrap; font-family:inherit;">${App.linkifyText(App.escapeHtml(a.a_body || ''))}</div>`;
const visibleAttachments = articleAttachments.filter(att => {
const fname = (att.filename || '').toLowerCase();
if (fname.startsWith('file-1') || fname.startsWith('file-2') || fname.startsWith('file-3')) {
return false;
}
if (usedAttachmentIds.has(att.id)) {
return false;
}
return true;
});
const extractedLinks = this.extractLinks(processedBody || a.a_body || '');
return `
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}" style="margin-bottom: var(--space-md);">
<div class="article-header">
<div class="article-sender">
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</span>
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
<button class="btn-delete-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(--danger); transition:all 0.15s ease; opacity:1;" title="Elimina Articolo">
<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>
<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% + 4px); right: 0; left: auto; width: 340px; max-height: 360px; overflow-y: auto; overscroll-behavior: contain; 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; user-select: text; -webkit-user-select: text;">
<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>
Dettagli Email #${a.article_id}
</span>
</div>
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35; display: flex; align-items: flex-start; justify-content: space-between; gap: 6px;">
<div>
<strong style="color: var(--text-muted);">Da:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || '—')}</span>
</div>
${(a.a_from) ? `
<button class="btn-copy-email-addr" data-copy-text="${App.escapeHtml(a.a_from)}" title="Copia Da" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:2px 5px; font-size:0.7rem; flex-shrink:0; color:var(--text-secondary);">📋 Copia</button>
` : ''}
</div>
${a.a_to ? `
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35; display: flex; align-items: flex-start; justify-content: space-between; gap: 6px;">
<div>
<strong style="color: var(--text-muted);">A:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_to)}</span>
</div>
<button class="btn-copy-email-addr" data-copy-text="${App.escapeHtml(a.a_to)}" title="Copia A" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:2px 5px; font-size:0.7rem; flex-shrink:0; color:var(--text-secondary);">📋 Copia</button>
</div>
` : ''}
${a.a_cc ? `
<div style="margin-bottom: 6px; word-break: break-all; line-height: 1.35; display: flex; align-items: flex-start; justify-content: space-between; gap: 6px;">
<div>
<strong style="color: var(--text-muted);">Cc:</strong>
<span style="color: var(--text-primary); font-family: inherit;">${App.escapeHtml(a.a_cc)}</span>
</div>
<button class="btn-copy-email-addr" data-copy-text="${App.escapeHtml(a.a_cc)}" title="Copia Cc" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:2px 5px; font-size:0.7rem; flex-shrink:0; color:var(--text-secondary);">📋 Copia</button>
</div>
` : ''}
<div style="font-weight: 700; color: var(--text-secondary); margin-top: 10px; margin-bottom: 6px; border-bottom: 1px solid var(--border-subtle); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between; font-size: 0.78rem;">
<span style="display: flex; align-items: center; gap: 4px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;color:var(--accent-primary);"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Link contenuti
</span>
<span class="badge" style="font-size: 0.7rem; padding: 1px 6px; background: var(--bg-tertiary);">${extractedLinks.length}</span>
</div>
${extractedLinks.length > 0 ? `
<div class="article-links-minilist" style="display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; overscroll-behavior: contain; padding-right: 4px;">
${extractedLinks.map(link => `
<a href="${App.escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" title="${App.escapeHtml(link.url)}" style="color: var(--accent-primary); text-decoration: none; word-break: break-all; font-size: 0.75rem; display: flex; align-items: flex-start; gap: 4px; line-height: 1.3; background: var(--bg-tertiary); padding: 4px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);">
<span style="flex-shrink:0; font-size: 0.7rem;">🌐</span>
<span style="overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word;">${App.escapeHtml(link.text || link.url)}</span>
</a>
`).join('')}
</div>
` : `
<div style="color: var(--text-muted); font-size: 0.75rem; font-style: italic;">Nessun link presente</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>` : ''}
<div class="article-body-main" style="width: 100%;">
${displayBody}
${visibleAttachments.length > 0 ? `
<div class="article-attachments">
${visibleAttachments.map(att => `
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}" data-is-image="${(att.content_type || '').startsWith('image/')}">
<span>📎</span>
<strong>${App.escapeHtml(att.filename)}</strong>
<span class="attachment-size">(${Math.round(att.content_size / 1024)} KB)</span>
</a>
`).join('')}
</div>
` : ''}
</div>
</div>
`;
}).join('') : `
<div class="empty-state" style="padding:var(--space-lg);">
<div class="empty-state-icon">💬</div>
<div class="empty-state-text">Nessun articolo</div>
</div>
`}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="ticket-sidebar">
<div class="sidebar-panel">
<div class="sidebar-panel-title">Dettagli</div>
<div class="meta-row">
<span class="meta-label">Numero</span>
<span class="meta-value" style="font-family:monospace;">${ticket.tn}</span>
</div>
<div class="meta-row">
<span class="meta-label">Stato</span>
<span class="meta-value"><span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span></span>
</div>
<div class="meta-row">
<span class="meta-label">Creato</span>
<span class="meta-value">
<div class="retrodate-container" id="ticket-retrodate-container">
<span id="ticket-date-text">${App.formatDateTime(ticket.create_time)}</span>
<button class="retrodate-btn-edit" id="btn-edit-ticket-date" title="Retrodata Creazione Ticket">✏️</button>
<div class="retrodate-editor" id="ticket-date-editor" style="display:none;">
<input type="datetime-local" class="retrodate-input" id="input-ticket-date" value="${localISO(ticket.create_time)}" />
<div class="retrodate-actions">
<button class="retrodate-btn-action save" id="btn-save-ticket-date" title="Salva">✓</button>
<button class="retrodate-btn-action cancel" id="btn-cancel-ticket-date" title="Annulla">✗</button>
</div>
</div>
</div>
</span>
</div>
<div class="meta-row">
<span class="meta-label">Modificato</span>
<span class="meta-value">${App.formatDateTime(ticket.change_time)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Lock</span>
<span class="meta-value">${ticket.lock_name || 'unlock'}</span>
</div>
${ticket.type_name ? `
<div class="meta-row">
<span class="meta-label">Tipo</span>
<span class="meta-value">${ticket.type_name}</span>
</div>
` : ''}
<div class="meta-row">
<span class="meta-label">Owner</span>
<span class="meta-value">${ticket.owner_first ? `${ticket.owner_first} ${ticket.owner_last}` : (ticket.owner_login || '—')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Responsabile</span>
<span class="meta-value">${ticket.responsible_first ? `${ticket.responsible_first} ${ticket.responsible_last}` : '—'}</span>
</div>
${totalTime > 0 ? `
<div class="meta-row">
<span class="meta-label">Tempo Totale</span>
<span class="meta-value" style="font-weight:bold;color:var(--info);">⏱ ${totalTime} min</span>
</div>
` : ''}
</div>
${ticket.customer_user_id || ticket.customer_id ? `
<div class="sidebar-panel">
<div class="sidebar-panel-title">Cliente</div>
${ticket.customer_first ? `
<div class="meta-row">
<span class="meta-label">Nome</span>
<span class="meta-value">${ticket.customer_first} ${ticket.customer_last}</span>
</div>
` : ''}
${ticket.customer_email ? `
<div class="meta-row">
<span class="meta-label">Email</span>
<span class="meta-value" style="font-size:0.78rem;">${ticket.customer_email}</span>
</div>
` : ''}
${ticket.customer_phone ? `
<div class="meta-row">
<span class="meta-label">Telefono</span>
<span class="meta-value">${ticket.customer_phone}</span>
</div>
` : ''}
${ticket.customer_id ? `
<div class="meta-row">
<span class="meta-label">Customer ID</span>
<span class="meta-value" style="font-size:0.78rem;">${ticket.customer_id}</span>
</div>
` : ''}
</div>
` : ''}
${ticket.escalation_time > 0 ? `
<div class="sidebar-panel" style="border-color: rgba(239,68,68,0.3);">
<div class="sidebar-panel-title" style="color:var(--error);">⚠ Escalation</div>
<div class="meta-row">
<span class="meta-label">Tempo</span>
<span class="meta-value" style="color:var(--error);">${new Date(ticket.escalation_time * 1000).toLocaleString('it-IT')}</span>
</div>
</div>
` : ''}
</div>
</div>
`;
if (window.Quill) {
this.noteQuill = new Quill('#note-body-editor', {
theme: 'snow',
placeholder: 'Scrivi una nota interna...',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'align': [] }],
['link', 'image', 'code-block'],
['clean']
],
keyboard: {
bindings: {
tab: {
key: 'Tab',
handler: function () {
return true;
}
}
}
}
}
});
// Prevent tab navigation on toolbar items
const toolbar = container.querySelector('.ql-toolbar');
if (toolbar) {
toolbar.querySelectorAll('button, select, span[role="button"], input').forEach(el => {
el.setAttribute('tabindex', '-1');
});
}
} else {
this.noteQuill = null;
}
// Restore Note Draft
const noteDraft = App.getDraft(id, 'note');
if (noteDraft) {
if (this.noteQuill && noteDraft.body) {
this.noteQuill.root.innerHTML = noteDraft.body;
}
if (document.getElementById('note-subject')) {
document.getElementById('note-subject').value = noteDraft.subject || '';
}
if (document.getElementById('note-time-units')) {
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
}
if (document.getElementById('note-time-units-bottom')) {
document.getElementById('note-time-units-bottom').value = noteDraft.time_unit || '';
}
if (document.getElementById('note-ticket-state') && noteDraft.ticket_state_id) {
document.getElementById('note-ticket-state').value = noteDraft.ticket_state_id;
}
if (document.getElementById('note-create-time') && noteDraft.note_create_time) {
document.getElementById('note-create-time').value = noteDraft.note_create_time;
}
this.noteAttachments = noteDraft.attachments || [];
this.updateNoteAttachmentList();
}
this.bindEvents(ticket, articles, container, groupsData, attachments);
} catch (err) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Errore caricamento ticket</div>
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
<button class="btn btn-ghost" style="margin-top:var(--space-md);" onclick="history.back()">Torna indietro</button>
</div>
`;
}
},
bindEvents(ticket, articles, container, groupsData, attachments) {
// Quick-edit change detection
const fields = document.querySelectorAll('.quick-edit-select:not(#qe-queue-search), #qe-queue, #qe-customer-user-id, #qe-customer-id');
const saveBtn = document.getElementById('qe-save');
const resetBtn = document.getElementById('qe-reset');
const queueSearchInput = document.getElementById('qe-queue-search');
const queueIdInput = document.getElementById('qe-queue');
const customerSearchInput = document.getElementById('qe-customer-search');
const customerUserIdInput = document.getElementById('qe-customer-user-id');
const customerIdInput = document.getElementById('qe-customer-id');
const noteSubjectInput = document.getElementById('note-subject');
const noteTimeUnitsInput = document.getElementById('note-time-units');
const noteTimeUnitsBottomInput = document.getElementById('note-time-units-bottom');
const noteCreateTimeInput = document.getElementById('note-create-time');
const checkChanges = () => {
let hasChanges = false;
fields.forEach(el => {
const field = el.dataset.field;
const original = String(this.originalValues[field] || '');
const current = el.value;
const changed = current !== original;
if (el.id === 'qe-queue') {
if (queueSearchInput) queueSearchInput.classList.toggle('changed', changed);
} else {
el.classList.toggle('changed', changed);
}
if (changed) hasChanges = true;
});
const noteSubText = noteSubjectInput ? noteSubjectInput.value.trim() : '';
if (noteSubText.length > 0) hasChanges = true;
const noteBodyText = (this.noteQuill ? this.noteQuill.root.innerText : '').trim();
if (noteBodyText.length > 0) hasChanges = true;
const timeValStr = (noteTimeUnitsInput ? noteTimeUnitsInput.value : '') || (noteTimeUnitsBottomInput ? noteTimeUnitsBottomInput.value : '');
const noteTimeUnitsVal = parseFloat(timeValStr || '0') || 0;
if (noteTimeUnitsVal > 0) hasChanges = true;
if (noteCreateTimeInput && noteCreateTimeInput.value) hasChanges = true;
if (this.noteAttachments && this.noteAttachments.length > 0) hasChanges = true;
if (saveBtn) saveBtn.disabled = !hasChanges;
};
fields.forEach(el => {
el.addEventListener('change', checkChanges);
});
if (noteSubjectInput) {
noteSubjectInput.addEventListener('input', checkChanges);
}
if (noteTimeUnitsInput) {
noteTimeUnitsInput.addEventListener('input', () => {
if (noteTimeUnitsBottomInput && noteTimeUnitsBottomInput.value !== noteTimeUnitsInput.value) {
noteTimeUnitsBottomInput.value = noteTimeUnitsInput.value;
}
checkChanges();
});
}
if (noteTimeUnitsBottomInput) {
noteTimeUnitsBottomInput.addEventListener('input', () => {
if (noteTimeUnitsInput && noteTimeUnitsInput.value !== noteTimeUnitsBottomInput.value) {
noteTimeUnitsInput.value = noteTimeUnitsBottomInput.value;
}
checkChanges();
});
}
if (noteCreateTimeInput) {
noteCreateTimeInput.addEventListener('change', checkChanges);
const setDefaultNoteTime = () => {
if (!noteCreateTimeInput.value && ticket && ticket.create_time) {
const createDate = new Date(ticket.create_time);
if (!isNaN(createDate.getTime())) {
const plus5Min = new Date(createDate.getTime() + 5 * 60 * 1000);
const localISO = new Date(plus5Min.getTime() - plus5Min.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
noteCreateTimeInput.value = localISO;
checkChanges();
}
}
};
noteCreateTimeInput.addEventListener('focus', setDefaultNoteTime);
noteCreateTimeInput.addEventListener('click', setDefaultNoteTime);
}
if (this.noteQuill) {
this.noteQuill.on('text-change', checkChanges);
}
// Reset quick-edit & note form
if (resetBtn) {
resetBtn.addEventListener('click', () => {
fields.forEach(el => {
el.value = this.originalValues[el.dataset.field] || '';
el.classList.remove('changed');
});
if (queueSearchInput) {
queueSearchInput.classList.remove('changed');
queueSearchInput.value = this.originalValues['queue_name'] || '';
}
if (customerSearchInput) {
const first = this.originalValues['customer_first'] || '';
const last = this.originalValues['customer_last'] || '';
customerSearchInput.value = first ? `${first} ${last}` : (this.originalValues['customer_user_id'] || '');
}
if (noteSubjectInput) noteSubjectInput.value = '';
if (this.noteQuill) this.noteQuill.root.innerHTML = '';
if (noteTimeUnitsInput) noteTimeUnitsInput.value = '';
if (noteTimeUnitsBottomInput) noteTimeUnitsBottomInput.value = '';
if (noteCreateTimeInput) noteCreateTimeInput.value = '';
this.noteAttachments = [];
this.updateNoteAttachmentList();
App.clearDraft(this.ticketId, 'note');
if (saveBtn) saveBtn.disabled = true;
});
}
// Queue Search Autocomplete
const queueSuggestionsDiv = document.getElementById('qe-queue-suggestions');
let queueDebounce;
if (queueSearchInput && queueSuggestionsDiv) {
queueSearchInput.addEventListener('input', () => {
clearTimeout(queueDebounce);
const q = queueSearchInput.value.trim();
const origQueueName = (this.originalValues['queue_name'] || '').trim();
if (q !== origQueueName) {
if (queueIdInput) queueIdInput.value = '';
} else {
if (queueIdInput) queueIdInput.value = String(this.originalValues['queue_id'] || '');
}
checkChanges();
queueDebounce = setTimeout(async () => {
try {
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
if (!queues || queues.length === 0) {
queueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default; padding: 6px 12px; font-size:0.78rem;">Nessuna coda trovata</div>';
queueSuggestionsDiv.style.display = 'block';
return;
}
queueSuggestionsDiv.innerHTML = queues.map(qObj => {
const displayName = qObj.name.replace(/::/g, ' › ');
return `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(qObj.id)}" data-name="${App.escapeHtml(qObj.name)}" style="padding: 6px 12px; font-size: 0.78rem; line-height: 1.25; cursor: pointer;">
${App.escapeHtml(displayName)}
</div>
`;
}).join('');
queueSuggestionsDiv.style.display = 'block';
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
item.addEventListener('mousedown', (e) => {
e.preventDefault();
queueSearchInput.value = item.dataset.name;
if (queueIdInput) queueIdInput.value = item.dataset.id;
queueSuggestionsDiv.style.display = 'none';
checkChanges();
});
}
});
} catch (err) {
console.error('Queue search error:', err);
}
}, 150);
});
if (App.setupAutocompleteKeyboardNavigation) {
App.setupAutocompleteKeyboardNavigation(queueSearchInput, queueSuggestionsDiv, (item) => {
if (item && item.dataset.id) {
queueSearchInput.value = item.dataset.name;
if (queueIdInput) queueIdInput.value = item.dataset.id;
queueSuggestionsDiv.style.display = 'none';
checkChanges();
}
});
}
queueSearchInput.addEventListener('focus', () => {
queueSearchInput.dispatchEvent(new Event('input'));
});
}
// Customer Search Autocomplete
const customerSuggestionsDiv = document.getElementById('qe-customer-suggestions');
let customerDebounce;
if (customerSearchInput && customerSuggestionsDiv) {
customerSearchInput.addEventListener('input', () => {
clearTimeout(customerDebounce);
const q = customerSearchInput.value.trim();
const origCustFirst = (this.originalValues['customer_first'] || '').trim();
const origCustLast = (this.originalValues['customer_last'] || '').trim();
const origCustName = origCustFirst ? `${origCustFirst} ${origCustLast}`.trim() : (this.originalValues['customer_user_id'] || '').trim();
if (q !== origCustName) {
if (customerUserIdInput) customerUserIdInput.value = '';
if (customerIdInput) customerIdInput.value = '';
} else {
if (customerUserIdInput) customerUserIdInput.value = this.originalValues['customer_user_id'] || '';
if (customerIdInput) customerIdInput.value = this.originalValues['customer_id'] || '';
}
checkChanges();
customerDebounce = setTimeout(async () => {
try {
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
if (!users || users.length === 0) {
customerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default; padding: 6px 12px; font-size:0.78rem;">Nessun cliente trovato</div>';
customerSuggestionsDiv.style.display = 'block';
return;
}
customerSuggestionsDiv.innerHTML = users.map(u => {
const fullName = (u.first_name || u.last_name) ? `${u.first_name || ''} ${u.last_name || ''}`.trim() : (u.login || '');
return `
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login || '')}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(fullName)}" style="padding: 6px 12px; font-size: 0.78rem; cursor: pointer;">
<strong>${App.escapeHtml(fullName)}</strong>
<span style="color:var(--text-muted); margin-left:6px; font-size:0.75rem;">(Login: ${App.escapeHtml(u.login || '')}${u.customer_id ? ` | ${App.escapeHtml(u.customer_id)}` : ''})</span>
${u.email ? `<div style="color:var(--text-secondary); font-size:0.73rem;">${App.escapeHtml(u.email)}</div>` : ''}
</div>
`;
}).join('');
customerSuggestionsDiv.style.display = 'block';
const selectCustomerItem = (item) => {
customerSearchInput.value = item.dataset.name;
if (customerUserIdInput) customerUserIdInput.value = item.dataset.login;
if (customerIdInput) customerIdInput.value = item.dataset.customerId;
customerSuggestionsDiv.style.display = 'none';
checkChanges();
};
customerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.login) {
item.addEventListener('mousedown', (e) => {
e.preventDefault();
selectCustomerItem(item);
});
}
});
} catch (err) {
console.error('Customer search error:', err);
}
}, 150);
});
if (App.setupAutocompleteKeyboardNavigation) {
App.setupAutocompleteKeyboardNavigation(customerSearchInput, customerSuggestionsDiv, (item) => {
if (item && item.dataset.login) {
customerSearchInput.value = item.dataset.name;
if (customerUserIdInput) customerUserIdInput.value = item.dataset.login;
if (customerIdInput) customerIdInput.value = item.dataset.customerId;
customerSuggestionsDiv.style.display = 'none';
checkChanges();
}
});
}
customerSearchInput.addEventListener('focus', () => {
customerSearchInput.dispatchEvent(new Event('input'));
});
}
// Close suggestions on click outside
document.addEventListener('click', (e) => {
if (queueSearchInput && queueSuggestionsDiv && e.target !== queueSearchInput && !queueSuggestionsDiv.contains(e.target)) {
queueSuggestionsDiv.style.display = 'none';
}
if (customerSearchInput && customerSuggestionsDiv && e.target !== customerSearchInput && !customerSuggestionsDiv.contains(e.target)) {
customerSuggestionsDiv.style.display = 'none';
}
});
// Unified Save Quick Edit & Send Note action
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const queueSearch = queueSearchInput ? queueSearchInput.value.trim() : '';
if (queueSearchInput && !queueIdInput.value) {
const origName = (this.originalValues['queue_name'] || '').trim();
if (queueSearch === origName && this.originalValues['queue_id']) {
queueIdInput.value = String(this.originalValues['queue_id']);
} else {
Toast.warning('Seleziona una coda valida dall\'elenco.');
return;
}
}
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
const origCustFirst = (this.originalValues['customer_first'] || '').trim();
const origCustLast = (this.originalValues['customer_last'] || '').trim();
const origCustName = origCustFirst ? `${origCustFirst} ${origCustLast}`.trim() : (this.originalValues['customer_user_id'] || '').trim();
if (customerSearchInput) {
if (!customerSearch) {
if (customerUserIdInput) customerUserIdInput.value = '';
if (customerIdInput) customerIdInput.value = '';
} else if (customerSearch === origCustName) {
if (customerUserIdInput) customerUserIdInput.value = this.originalValues['customer_user_id'] || '';
if (customerIdInput) customerIdInput.value = this.originalValues['customer_id'] || '';
}
}
// Check metadata updates
const updates = {};
fields.forEach(el => {
const field = el.dataset.field;
let val;
if (field === 'customer_id' || field === 'customer_user_id') {
val = el.value || null;
} else {
val = el.value ? parseInt(el.value) : null;
}
const origVal = this.originalValues[field] || null;
if (val !== origVal) {
updates[field] = val;
}
});
// Check note content
let noteBody = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
if (noteBody === '<p><br></p>') noteBody = '';
let noteSubject = noteSubjectInput ? noteSubjectInput.value.trim() : '';
const timeUnitVal = noteTimeUnitsInput ? noteTimeUnitsInput.value.trim() : '';
const noteCreateTimeVal = noteCreateTimeInput ? noteCreateTimeInput.value : '';
const hasMetadataChanges = Object.keys(updates).length > 0;
const hasNoteContent = !!(noteBody || noteSubject || timeUnitVal || this.noteAttachments.length > 0 || noteCreateTimeVal);
if (!hasMetadataChanges && !hasNoteContent) {
Toast.warning('Nessuna modifica o nota da salvare');
return;
}
try {
saveBtn.disabled = true;
saveBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;"></div> Salvataggio...';
// 1. Perform metadata updates if changed
if (hasMetadataChanges) {
await App.api(`/api/tickets/${this.ticketId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
}
// 2. Perform article note creation if note fields are populated
if (hasNoteContent) {
if (!noteSubject && !noteBody) {
noteSubject = 'consuntivazione';
noteBody = '<p>consuntivazione</p>';
} else if (!noteBody && noteSubject) {
noteBody = `<p>${App.escapeHtml(noteSubject)}</p>`;
} else if (noteBody && !noteSubject) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = noteBody;
const plainText = tempDiv.textContent || tempDiv.innerText || '';
noteSubject = plainText.trim().substring(0, 50) || 'consuntivazione';
}
const resNote = await App.api(`/api/tickets/${this.ticketId}/articles`, {
method: 'POST',
body: JSON.stringify({
subject: noteSubject,
body: noteBody,
time_unit: timeUnitVal,
attachments: this.noteAttachments.length > 0 ? this.noteAttachments : undefined
}),
});
this.noteAttachments = []; // Clear attachments after successful send
if (noteCreateTimeVal && resNote.article_id) {
try {
const formattedDate = noteCreateTimeVal.replace('T', ' ');
await App.api(`/api/tickets/articles/${resNote.article_id}/retrodata-article`, {
method: 'POST',
body: JSON.stringify({ create_time: formattedDate })
});
} catch (retErr) {
console.warn('Note retrodate error:', retErr);
}
}
}
Toast.success('Modifiche e note salvate con successo!');
App.clearDraft(this.ticketId, 'note');
App.updateDailyTimer();
TicketListView.invalidateCache();
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore durante il salvataggio: ' + err.message);
saveBtn.disabled = false;
}
});
}
// Note Attachments Upload Handlers
const noteAttachBtn = document.getElementById('btn-note-add-attachments');
const noteFileInput = document.getElementById('note-attachments');
if (noteAttachBtn && noteFileInput) {
noteAttachBtn.addEventListener('click', () => noteFileInput.click());
noteFileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
for (const file of files) {
const reader = new FileReader();
reader.onload = () => {
const base64Data = reader.result.split(',')[1];
this.noteAttachments.push({
filename: file.name,
content: base64Data,
content_type: file.type
});
this.updateNoteAttachmentList();
};
reader.readAsDataURL(file);
}
noteFileInput.value = ''; // Reset input
});
}
// Email Compose Button
const btnEmailCompose = document.getElementById('btn-open-email-compose');
if (btnEmailCompose) {
btnEmailCompose.addEventListener('click', () => {
if (window.EmailCompose) {
const tId = parseInt(btnEmailCompose.dataset.ticketId, 10);
const emailDraft = App.getDraft(tId, 'email');
if (emailDraft) {
EmailCompose.open({
...emailDraft.options,
draft: emailDraft
});
} else {
EmailCompose.open({
ticketId: tId,
ticketTn: btnEmailCompose.dataset.ticketTn,
ticketTitle: btnEmailCompose.dataset.ticketTitle,
customerEmail: btnEmailCompose.dataset.customerEmail,
});
}
} else {
Toast.error('Modulo email non disponibile');
}
});
}
// Article Email Reply Buttons
document.querySelectorAll('.btn-email-article').forEach(btn => {
btn.addEventListener('click', () => {
const articleId = parseInt(btn.dataset.articleId, 10);
const article = articles.find(art => art.article_id == articleId);
if (!article) return;
if (window.EmailCompose) {
const rawQuotedBody = article.html_body || article.a_body || '';
const hasHtml = article.has_html || (article.a_content_type || '').toLowerCase().includes('html') || rawQuotedBody.includes('</') || rawQuotedBody.includes('/>');
let processedQuoted = rawQuotedBody;
if (hasHtml) {
const articleAttachments = (attachments || []).filter(att => att.article_id === article.article_id);
articleAttachments.forEach(att => {
if (att.content_id) {
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
if (cleanCid) {
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
processedQuoted = processedQuoted.replace(regex, `/api/tickets/attachments/${att.id}`);
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
processedQuoted = processedQuoted.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
}
});
}
const quotedBody = hasHtml ? processedQuoted : App.escapeHtml(article.a_body || '').replace(/\n/g, '<br>');
const initialBodyHtml = `
<p><br></p>
<p style="font-size:0.88rem; color:var(--text-muted); margin-bottom:8px;">Il ${App.formatDateTime(article.create_time)}, <strong>${App.escapeHtml(article.a_from || 'Sistema')}</strong> ha scritto:</p>
<blockquote style="border-left: 3px solid var(--accent-primary, #3b82f6); background: rgba(255, 255, 255, 0.03); border-radius: 0 6px 6px 0; padding: 10px 14px; margin: 4px 0 12px 0; color: inherit;">
${quotedBody}
</blockquote>
<p><br></p>
`;
// Helper to extract email addresses from headers
const parseEmails = (str) => {
if (!str) return [];
return (str.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [])
.map(email => email.toLowerCase().trim());
};
const fromEmails = parseEmails(article.a_from);
const toEmails = parseEmails(article.a_to);
const ccEmails = parseEmails(article.a_cc);
// Exclude list (helpdesk and active agent)
const config = App.lookups.config || {};
const helpdeskEmail = (config.helpdeskEmail || 'helpdesk@pharmaidea.com').toLowerCase();
const agentEmail = (config.agentEmail || '').toLowerCase();
const excludeEmails = [helpdeskEmail, agentEmail].filter(Boolean);
const initialToSet = new Set();
fromEmails.forEach(e => {
if (!excludeEmails.includes(e) && !e.includes('helpdesk')) {
initialToSet.add(e);
}
});
if (initialToSet.size === 0 && ticket.customer_email) {
initialToSet.add(ticket.customer_email.toLowerCase());
}
const initialCcSet = new Set();
[...toEmails, ...ccEmails].forEach(e => {
if (!excludeEmails.includes(e) && !e.includes('helpdesk') && !initialToSet.has(e)) {
initialCcSet.add(e);
}
});
let inReplyTo = article.a_message_id || '';
let references = '';
if (article.a_references) {
references = article.a_references + (article.a_message_id ? ' ' + article.a_message_id : '');
} else if (article.a_message_id) {
references = article.a_message_id;
}
EmailCompose.open({
ticketId: ticket.id,
ticketTn: ticket.tn,
ticketTitle: ticket.title,
initialTo: Array.from(initialToSet),
initialCc: Array.from(initialCcSet),
initialBodyHtml: initialBodyHtml,
inReplyTo: inReplyTo,
references: references,
});
} else {
Toast.error('Modulo email non disponibile');
}
});
});
// Event listener for email address copy buttons inside popover
container.querySelectorAll('.btn-copy-email-addr').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const textToCopy = btn.dataset.copyText || '';
if (textToCopy) {
navigator.clipboard.writeText(textToCopy).then(() => {
const origText = btn.textContent;
btn.textContent = '✓ Copiato!';
btn.style.color = 'var(--success)';
setTimeout(() => {
btn.textContent = origText;
btn.style.color = 'var(--text-secondary)';
}, 1500);
}).catch(err => {
Toast.error('Impossibile copiare: ' + err.message);
});
}
});
});
// Retrodate Ticket Event Listeners
const btnEditTicketDate = document.getElementById('btn-edit-ticket-date');
const ticketDateEditor = document.getElementById('ticket-date-editor');
const ticketDateText = document.getElementById('ticket-date-text');
const btnSaveTicketDate = document.getElementById('btn-save-ticket-date');
const btnCancelTicketDate = document.getElementById('btn-cancel-ticket-date');
const inputTicketDate = document.getElementById('input-ticket-date');
if (btnEditTicketDate) {
btnEditTicketDate.addEventListener('click', () => {
btnEditTicketDate.style.display = 'none';
ticketDateText.style.display = 'none';
ticketDateEditor.style.display = 'flex';
});
btnCancelTicketDate.addEventListener('click', () => {
ticketDateEditor.style.display = 'none';
btnEditTicketDate.style.display = 'inline-flex';
ticketDateText.style.display = 'inline';
});
btnSaveTicketDate.addEventListener('click', async () => {
const val = inputTicketDate.value;
if (!val) {
Toast.warning('Scegli una data/ora valida.');
return;
}
try {
btnSaveTicketDate.disabled = true;
await App.api(`/api/tickets/${this.ticketId}/retrodata-ticket`, {
method: 'POST',
body: JSON.stringify({ create_time: val })
});
Toast.success('Data creazione ticket aggiornata!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore retrodatazione ticket: ' + err.message);
btnSaveTicketDate.disabled = false;
}
});
}
// Retrodate Articles Event Listeners
document.querySelectorAll('.retrodate-container[data-article-id]').forEach(container => {
const articleId = container.dataset.articleId;
const btnEdit = container.querySelector('.btn-edit-article-date');
const editor = container.querySelector('.retrodate-editor');
const timeText = container.querySelector('.article-time');
const btnSave = container.querySelector('.btn-save-article-date');
const btnCancel = container.querySelector('.btn-cancel-article-date');
const input = container.querySelector('.input-article-date');
if (btnEdit) {
btnEdit.addEventListener('click', () => {
btnEdit.style.display = 'none';
timeText.style.display = 'none';
editor.style.display = 'flex';
});
btnCancel.addEventListener('click', () => {
editor.style.display = 'none';
btnEdit.style.display = 'inline-flex';
timeText.style.display = 'inline';
});
btnSave.addEventListener('click', async () => {
const val = input.value;
if (!val) {
Toast.warning('Scegli una data/ora valida.');
return;
}
try {
btnSave.disabled = true;
await App.api(`/api/tickets/articles/${articleId}/retrodata-article`, {
method: 'POST',
body: JSON.stringify({ create_time: val })
});
Toast.success('Data creazione articolo aggiornata!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore retrodatazione articolo: ' + err.message);
btnSave.disabled = false;
}
});
}
});
// Time Edit Event Listeners (Issue 21)
document.querySelectorAll('.time-edit-container[data-article-id]').forEach(container => {
const articleId = container.dataset.articleId;
const btnEdit = container.querySelector('.btn-edit-article-time');
const popover = container.querySelector('.time-editor-popover');
const btnSave = container.querySelector('.btn-save-article-time');
const btnCancel = container.querySelector('.btn-cancel-article-time');
const input = container.querySelector('.input-article-time');
if (btnEdit) {
btnEdit.addEventListener('click', (e) => {
e.stopPropagation();
// Close all other open time edit popovers
document.querySelectorAll('.time-editor-popover').forEach(p => {
if (p !== popover) p.style.display = 'none';
});
const isOpen = popover.style.display === 'block';
popover.style.display = isOpen ? 'none' : 'block';
if (!isOpen) {
input.focus();
input.select();
}
});
}
if (btnCancel) {
btnCancel.addEventListener('click', (e) => {
e.stopPropagation();
popover.style.display = 'none';
});
}
if (btnSave) {
btnSave.addEventListener('click', async (e) => {
e.stopPropagation();
const val = parseFloat(input.value);
if (isNaN(val) || val < 0) {
Toast.warning('Inserisci un valore numerico valido maggiore o uguale a 0.');
return;
}
try {
btnSave.disabled = true;
await App.api(`/api/tickets/articles/${articleId}/time`, {
method: 'PUT',
body: JSON.stringify({ time_unit: val })
});
Toast.success('Tempo consuntivato aggiornato con successo!');
popover.style.display = 'none';
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore durante l\'aggiornamento del tempo: ' + err.message);
btnSave.disabled = false;
}
});
}
// Stop propagation so clicking inside doesn't close it
if (popover) {
popover.addEventListener('click', (e) => e.stopPropagation());
}
});
// Article Info Popover (ℹ️) listeners & Copy email address buttons
document.querySelectorAll('.article-info-popover-wrapper').forEach(wrapper => {
const btnInfo = wrapper.querySelector('.btn-article-info');
const popover = wrapper.querySelector('.article-info-popover');
const card = wrapper.closest('.article-card');
const iframe = card ? card.querySelector('iframe') : null;
if (btnInfo && popover) {
btnInfo.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = popover.style.display === 'block';
// Close all popovers and restore pointer-events on all iframes
document.querySelectorAll('.article-info-popover, .time-editor-popover').forEach(p => p.style.display = 'none');
document.querySelectorAll('.article-card iframe').forEach(f => f.style.pointerEvents = 'auto');
if (!isOpen) {
popover.style.display = 'block';
if (iframe) iframe.style.pointerEvents = 'none';
}
});
popover.addEventListener('click', (e) => e.stopPropagation());
popover.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
}
});
document.querySelectorAll('.btn-copy-email-addr').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const text = btn.dataset.copyText;
if (text) {
navigator.clipboard.writeText(text);
Toast.success('Indirizzo copiato negli appunti!');
}
});
});
// Close popovers clicking outside
document.addEventListener('click', () => {
document.querySelectorAll('.time-editor-popover, .article-info-popover').forEach(p => {
p.style.display = 'none';
});
document.querySelectorAll('.article-card iframe').forEach(f => f.style.pointerEvents = 'auto');
});
// Delete Article Event Listeners
document.querySelectorAll('.btn-delete-article').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const articleId = btn.dataset.articleId;
const ok = await App.confirm(
"Elimina Articolo",
"Sei sicuro di voler eliminare questa nota/articolo? Tutti i file allegati e i tempi ad esso associati verranno rimossi permanentemente.",
{ confirmText: 'Elimina', cancelText: 'Annulla' }
);
if (ok) {
try {
btn.disabled = true;
await App.api(`/api/tickets/articles/${articleId}`, {
method: 'DELETE'
});
Toast.success('Articolo/nota eliminato con successo!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore durante l\'eliminazione: ' + err.message);
btn.disabled = false;
}
}
});
});
// HTML Mode Toggle Listener
const htmlToggle = document.getElementById('html-toggle');
if (htmlToggle) {
htmlToggle.addEventListener('change', (e) => {
const isChecked = e.target.checked;
localStorage.setItem('otrs_turbo_html_mode', isChecked);
this.htmlMode = isChecked;
this.render(this.ticketId);
});
}
// Populate Group Association dropdown
App.api('/api/groups').then(allGroups => {
const select = document.getElementById('group-association-select');
if (select) {
allGroups.forEach(g => {
const isMember = groupsData.asMember.some(m => m.id === g.id);
const isMaster = groupsData.asMaster.some(m => m.id === g.id);
if (!isMember && !isMaster) {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.nome;
select.appendChild(opt);
}
});
}
}).catch(err => console.warn('Failed to load groups for association dropdown', err));
const btnAssociate = document.getElementById('btn-associate-to-group');
if (btnAssociate) {
btnAssociate.addEventListener('click', async () => {
const select = document.getElementById('group-association-select');
const groupId = select.value;
if (!groupId) {
sessionStorage.setItem('otrs_create_group_with_master_tn', ticket.tn);
window.location.hash = '#/tickets/groups';
return;
}
try {
btnAssociate.disabled = true;
await App.api(`/api/groups/${groupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: this.ticketId })
});
Toast.success('Ticket associato al gruppo!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore associazione: ' + err.message);
btnAssociate.disabled = false;
}
});
}
// Inline image preview & lightbox binding
document.querySelectorAll('.articles-timeline iframe').forEach(iframe => {
const attachImageClick = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) {
doc.querySelectorAll('img').forEach(img => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(img.src);
});
});
}
} catch (err) {
console.warn('Cannot attach click listeners to iframe images', err);
}
};
iframe.addEventListener('load', attachImageClick);
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc && doc.readyState === 'complete') {
attachImageClick();
}
} catch (_) { }
});
document.querySelectorAll('.attachment-badge').forEach(badge => {
badge.style.cursor = 'pointer';
badge.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(badge.href);
});
});
document.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success('Copiato negli appunti!');
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
document.querySelectorAll('.copy-full-ticket-title').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const fullText = el.dataset.full;
if (fullText) {
navigator.clipboard.writeText(fullText).then(() => {
Toast.success('TN e Oggetto copiati negli appunti!');
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
const headerAttBtn = document.querySelector('.header-open-attachments-btn');
if (headerAttBtn) {
headerAttBtn.addEventListener('click', (e) => {
e.stopPropagation();
const firstBadge = document.querySelector('.attachment-badge');
const firstSrc = firstBadge ? firstBadge.href : '';
this.openImageLightbox(firstSrc);
});
}
// Keydown listener for Ticket Detail shortcut (Save Quick Edit / Send Note)
const handleTicketDetailShortcut = (e) => {
if (!document.getElementById('qe-save')) {
document.removeEventListener('keydown', handleTicketDetailShortcut, true);
return;
}
if (App.isShortcutTriggered(e, 'quick_edit_save')) {
e.preventDefault();
e.stopPropagation();
const btn = document.getElementById('qe-save');
if (btn && !btn.disabled) {
btn.click();
}
}
};
document.addEventListener('keydown', handleTicketDetailShortcut, true);
// Set focus on State dropdown (#qe-state) on page load so Tab navigation starts from State
setTimeout(() => {
const qeStateEl = document.getElementById('qe-state');
if (qeStateEl) {
qeStateEl.focus();
}
}, 50);
},
};