4961 lines
238 KiB
JavaScript
4961 lines
238 KiB
JavaScript
/**
|
||
* 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 : '';
|
||
|
||
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0 || ticket_state_id !== '') {
|
||
App.saveDraft(this.ticketId, {
|
||
type: 'note',
|
||
body,
|
||
subject,
|
||
time_unit,
|
||
ticket_state_id,
|
||
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(/&/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, '"')}"
|
||
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;
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
},
|
||
};
|