Fix frontend JS: CSRF resync, status-comment flow, markdown/XSS, kanban
- base.js lt.api: resync window.CSRF_TOKEN from response bodies before throwing on errors and attach err.data/err.status, so a desynced client auto-recovers without a reload - add lt.ticketStatus.submit: status changes that require a comment now prompt, post the comment, and retry update_ticket with it; wired into the ticket dropdown, dashboard quick-status, kanban drag-drop and the 1-4 keyboard shortcuts (bulk ops unchanged) — matches the new server requires_comment enforcement - base.js markdown.render: drop the unsafe marked/markdownit delegation; always use the built-in XSS-safe renderer - ticket.js: XHR upload sends the X-CSRF-Token header and resyncs the token; use lt.escHtml instead of a re-inlined escape chain; @-mention trigger requires a word boundary (no firing inside emails); idempotent, anchor-safe highlightMentions - base.js typeahead: discard out-of-order async results - markdown.js: balanced table tbody/thead; ticket-ref linkification runs after code extraction so #ids inside code aren't linked - dashboard.js kanban: don't swallow the click after a drag - keyboard-shortcuts.js: J/K skip hidden/skeleton rows; drop duplicate ? Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+102
-6
@@ -468,7 +468,15 @@
|
||||
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
|
||||
let data;
|
||||
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
|
||||
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
// Resync CSRF token from any response body that carries a fresh one
|
||||
// (bootstrap rotates on success and returns the current token on rejection).
|
||||
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
||||
if (!resp.ok) {
|
||||
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
err.data = data;
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -2004,6 +2012,7 @@
|
||||
let _focusedIdx = -1;
|
||||
let _items = [];
|
||||
let _debTimer = null;
|
||||
let _searchSeq = 0;
|
||||
|
||||
function _render(items, query) {
|
||||
_items = items.slice(0, maxResults);
|
||||
@@ -2028,16 +2037,21 @@
|
||||
}
|
||||
|
||||
async function _search(query) {
|
||||
// Sequence guard: only the latest query is allowed to render, so a slow
|
||||
// earlier async source() cannot overwrite a newer query's results.
|
||||
const seq = ++_searchSeq;
|
||||
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
|
||||
dropdown.classList.add('is-open');
|
||||
inputEl.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
|
||||
if (seq !== _searchSeq) return;
|
||||
_render(results, query);
|
||||
} catch(e) {
|
||||
if (seq !== _searchSeq) return;
|
||||
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
|
||||
} finally {
|
||||
inputEl.setAttribute('aria-busy', 'false');
|
||||
if (seq === _searchSeq) inputEl.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2704,7 +2718,15 @@
|
||||
}
|
||||
let data;
|
||||
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
|
||||
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
// Resync CSRF token from any response body that carries a fresh one
|
||||
// (bootstrap rotates on success and returns the current token on rejection).
|
||||
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
||||
if (!resp.ok) {
|
||||
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
err.data = data;
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
api.get = url => _apiFetchAuth('GET', url);
|
||||
@@ -2713,6 +2735,79 @@
|
||||
api.patch = (u, b) => _apiFetchAuth('PATCH', u, b);
|
||||
api.delete = (u, b) => _apiFetchAuth('DELETE', u, b);
|
||||
|
||||
/* ================================================================
|
||||
TICKET STATUS CHANGE (comment-aware)
|
||||
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
|
||||
Posts /api/update_ticket.php. If the server rejects with
|
||||
requires_comment, opens a comment modal, persists the comment via
|
||||
/api/add_comment.php, then retries the update once WITH the comment.
|
||||
Rejects with err.cancelled === true if the user cancels the modal.
|
||||
================================================================ */
|
||||
function _statusCommentModal(newStatus) {
|
||||
return new Promise(resolve => {
|
||||
const modalId = 'ltStatusCommentModal' + Date.now();
|
||||
const safeStatus = escHtml(newStatus);
|
||||
document.body.insertAdjacentHTML('beforeend',
|
||||
'<div class="lt-modal-overlay" id="' + modalId + '" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="' + modalId + '_title">' +
|
||||
'<div class="lt-modal lt-modal-sm">' +
|
||||
'<div class="lt-modal-header" style="color:var(--terminal-amber)">' +
|
||||
'<span class="lt-modal-title" id="' + modalId + '_title">[ ! ] Change Status to ' + safeStatus + '</span>' +
|
||||
'<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="lt-modal-body">' +
|
||||
'<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">A comment is required when changing status to <strong>' + safeStatus + '</strong>. Enter your reason below.</p>' +
|
||||
'<textarea id="' + modalId + '_comment" class="lt-input lt-w-full" rows="3" placeholder="Reason for status change…" style="resize:vertical;font-family:inherit;font-size:0.8rem" aria-label="Required comment for status change"></textarea>' +
|
||||
'</div>' +
|
||||
'<div class="lt-modal-footer">' +
|
||||
'<button class="lt-btn lt-btn-primary" id="' + modalId + '_confirm">CONFIRM CHANGE</button>' +
|
||||
'<button class="lt-btn lt-btn-ghost" id="' + modalId + '_cancel">CANCEL</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>');
|
||||
const modalEl = document.getElementById(modalId);
|
||||
openModal(modalId);
|
||||
let done = false;
|
||||
const finish = (value) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
closeModal(modalId);
|
||||
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
|
||||
resolve(value);
|
||||
};
|
||||
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
|
||||
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
|
||||
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
|
||||
const ta = document.getElementById(modalId + '_comment');
|
||||
const comment = ta ? ta.value.trim() : '';
|
||||
if (!comment) { if (ta) ta.focus(); toast.warning('Please enter a reason for this status change.'); return; }
|
||||
finish(comment);
|
||||
});
|
||||
setTimeout(() => { const ta = document.getElementById(modalId + '_comment'); if (ta) ta.focus(); }, 100);
|
||||
});
|
||||
}
|
||||
|
||||
const ticketStatus = {
|
||||
submit(ticketId, newStatus, opts) {
|
||||
opts = opts || {};
|
||||
const id = String(ticketId);
|
||||
const payload = { ticket_id: id, status: newStatus };
|
||||
if (opts.comment) payload.comment = opts.comment;
|
||||
return api.post('/api/update_ticket.php', payload).catch(err => {
|
||||
if (!(err && err.data && err.data.requires_comment)) throw err;
|
||||
return _statusCommentModal(newStatus).then(comment => {
|
||||
if (!comment) {
|
||||
const cancelErr = new Error('Status change cancelled');
|
||||
cancelErr.cancelled = true;
|
||||
throw cancelErr;
|
||||
}
|
||||
// Persist the comment, then retry the status change with it included.
|
||||
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment })
|
||||
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment }));
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
MODULE 54 — MARKDOWN RENDERER
|
||||
lt.markdown.render(mdString) → HTML string (sanitized)
|
||||
@@ -2722,9 +2817,9 @@
|
||||
================================================================ */
|
||||
const markdown = {
|
||||
render(md) {
|
||||
// Delegate to window.marked if available
|
||||
if (global.marked) return global.marked.parse(md);
|
||||
if (global.markdownit) return global.markdownit().render(md);
|
||||
// Always use the built-in XSS-safe micro-renderer. Do NOT delegate to
|
||||
// window.marked / window.markdownit: their raw HTML output is not sanitized
|
||||
// here, so delegating would enable stored XSS if such a lib were ever loaded.
|
||||
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
|
||||
let html = escHtml(md)
|
||||
// Fenced code blocks
|
||||
@@ -2943,6 +3038,7 @@
|
||||
lightbox,
|
||||
auth,
|
||||
markdown,
|
||||
ticketStatus,
|
||||
pagination,
|
||||
sidebarSubmenus: { init: initSidebarSubmenus },
|
||||
};
|
||||
|
||||
+18
-11
@@ -1000,18 +1000,20 @@ function performQuickStatusChange(ticketId) {
|
||||
if (!quickStatusEl) return;
|
||||
const newStatus = quickStatusEl.value;
|
||||
|
||||
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
|
||||
// Close this modal first so the comment modal (if requires_comment) stacks cleanly.
|
||||
closeQuickStatusModal();
|
||||
|
||||
lt.ticketStatus.submit(ticketId, newStatus)
|
||||
.then(data => {
|
||||
closeQuickStatusModal();
|
||||
if (data.success) {
|
||||
if (data && data.success) {
|
||||
lt.toast.success(`Status updated to ${newStatus}`, 3000);
|
||||
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
lt.toast.error('Error: ' + ((data && data.error) || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
closeQuickStatusModal();
|
||||
if (error && error.cancelled) return;
|
||||
lt.toast.error('Error updating status', 4000);
|
||||
});
|
||||
}
|
||||
@@ -1168,8 +1170,9 @@ function populateKanbanCards() {
|
||||
card.dataset.ticketId = ticketId;
|
||||
card.dataset.status = status;
|
||||
card.addEventListener('click', (e) => {
|
||||
// Don't navigate if drag just ended (drag adds/removes is-dragging briefly)
|
||||
if (card.dataset.dragged) { delete card.dataset.dragged; return; }
|
||||
// Don't navigate if a drag just ended. The flag is cleared on a timer
|
||||
// (see handleKanbanSort), so a genuine later click is not swallowed.
|
||||
if (card.dataset.dragged) return;
|
||||
window.location.href = '/ticket/' + encodeURIComponent(ticketId);
|
||||
});
|
||||
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
|
||||
@@ -1214,6 +1217,9 @@ function populateKanbanCards() {
|
||||
|
||||
movedCard.dataset.status = newStatus;
|
||||
movedCard.dataset.dragged = '1';
|
||||
// Clear the drag flag shortly after the drop so it suppresses only the
|
||||
// synthetic click fired on drop, not the user's next genuine click.
|
||||
setTimeout(function () { delete movedCard.dataset.dragged; }, 400);
|
||||
|
||||
// Optimistically update column counts
|
||||
const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`);
|
||||
@@ -1230,8 +1236,9 @@ function populateKanbanCards() {
|
||||
if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')';
|
||||
};
|
||||
|
||||
// POST status update via the shared wrapper (adds CSRF + JSON, throws on non-2xx)
|
||||
lt.api.post('/api/update_ticket.php', { ticket_id: String(ticketId), status: newStatus })
|
||||
// Submit via the shared comment-aware helper. Dropping to Closed (or
|
||||
// reopening) prompts for a required comment and retries; cancel reverts.
|
||||
lt.ticketStatus.submit(String(ticketId), newStatus)
|
||||
.then(function (data) {
|
||||
if (data && data.success) {
|
||||
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
|
||||
@@ -1241,8 +1248,8 @@ function populateKanbanCards() {
|
||||
revert();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
lt.toast.error('Status update failed — reverting');
|
||||
.catch(function (error) {
|
||||
if (!(error && error.cancelled)) lt.toast.error('Status update failed — reverting');
|
||||
revert();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,11 +6,27 @@
|
||||
|
||||
// Track currently selected row for J/K navigation
|
||||
let currentSelectedRowIndex = -1;
|
||||
let lastNavRowCount = -1;
|
||||
|
||||
// Only navigate real, visible rows — skip skeleton placeholders and rows hidden
|
||||
// by filters/column toggles (offsetParent is null when display:none).
|
||||
function getNavigableRows() {
|
||||
return Array.from(document.querySelectorAll('tbody tr')).filter(function(row) {
|
||||
return !row.classList.contains('lt-skeleton-row') && row.offsetParent !== null;
|
||||
});
|
||||
}
|
||||
|
||||
function navigateTableRow(direction) {
|
||||
const rows = document.querySelectorAll('tbody tr');
|
||||
const rows = getNavigableRows();
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Reset the index when the row set changes (e.g. filter/reload) so navigation
|
||||
// never lands on a stale/hidden index.
|
||||
if (rows.length !== lastNavRowCount) {
|
||||
currentSelectedRowIndex = -1;
|
||||
lastNavRowCount = rows.length;
|
||||
}
|
||||
|
||||
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
||||
|
||||
if (direction === 'next') {
|
||||
@@ -47,10 +63,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
|
||||
lt.keys.on('?', function() {
|
||||
if (window.lt) lt.modal.open('lt-keys-help');
|
||||
});
|
||||
// Note: the '?' help shortcut is registered by lt.keys.initDefaults(); do not
|
||||
// re-bind it here or the help modal opens twice.
|
||||
|
||||
// J: Next row
|
||||
lt.keys.on('j', () => navigateTableRow('next'));
|
||||
|
||||
+21
-11
@@ -41,9 +41,6 @@ function parseMarkdown(markdown) {
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// Ticket references (#123456789) - convert to clickable links
|
||||
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||
|
||||
// Code blocks (```code```) - preserve content and don't process further
|
||||
const codeBlocks = [];
|
||||
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
|
||||
@@ -58,6 +55,11 @@ function parseMarkdown(markdown) {
|
||||
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
|
||||
});
|
||||
|
||||
// Ticket references (#123456789) - convert to clickable links.
|
||||
// Runs AFTER code extraction so a literal #123456789 inside inline/fenced code
|
||||
// (now replaced by a placeholder) is not turned into a link.
|
||||
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||
|
||||
// Tables (must be processed before other block elements)
|
||||
html = parseMarkdownTables(html);
|
||||
|
||||
@@ -287,25 +289,33 @@ function buildTable(rows) {
|
||||
if (rows.length === 0) return '';
|
||||
|
||||
let html = '<table class="markdown-table">';
|
||||
let inThead = false;
|
||||
let inTbody = false;
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
rows.forEach((row) => {
|
||||
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
|
||||
const tag = row.type === 'header' ? 'th' : 'td';
|
||||
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
|
||||
const isHeader = row.type === 'header';
|
||||
const tag = isHeader ? 'th' : 'td';
|
||||
|
||||
if (wrapper === 'thead') html += '<thead>';
|
||||
if (wrapper === 'tbody') html += '<tbody>';
|
||||
if (isHeader && !inThead) { html += '<thead>'; inThead = true; }
|
||||
if (!isHeader && !inTbody) {
|
||||
if (inThead) { html += '</thead>'; inThead = false; }
|
||||
html += '<tbody>';
|
||||
inTbody = true;
|
||||
}
|
||||
|
||||
html += '<tr>';
|
||||
cells.forEach(cell => {
|
||||
html += `<${tag}>${cell.trim()}</${tag}>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
|
||||
if (row.type === 'header') html += '</thead>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
// Close whichever section is still open so tags are balanced for header-only,
|
||||
// body-only, and header+body tables alike.
|
||||
if (inThead) html += '</thead>';
|
||||
if (inTbody) html += '</tbody>';
|
||||
html += '</table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
+40
-23
@@ -291,14 +291,8 @@ function addComment() {
|
||||
// For markdown, use parseMarkdown (sanitizes HTML)
|
||||
displayText = parseMarkdown(commentText);
|
||||
} else {
|
||||
// For non-markdown, convert line breaks to <br> and escape HTML
|
||||
displayText = commentText
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, '<br>');
|
||||
// For non-markdown, escape HTML then convert line breaks to <br>
|
||||
displayText = lt.escHtml(commentText).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// Add new comment to the list
|
||||
@@ -538,11 +532,12 @@ function updateTicketStatus() {
|
||||
return;
|
||||
}
|
||||
cleanup(true);
|
||||
// Post comment first, then change status
|
||||
// Post comment first (persists it), then change status with the same
|
||||
// comment included so the server's requires_comment check passes.
|
||||
const ticketId = getTicketIdFromUrl();
|
||||
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
|
||||
.then(() => performStatusChange(statusSelect, selectedOption, newStatus))
|
||||
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus));
|
||||
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment))
|
||||
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment));
|
||||
});
|
||||
// Focus textarea on open
|
||||
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
|
||||
@@ -552,8 +547,11 @@ function updateTicketStatus() {
|
||||
performStatusChange(statusSelect, selectedOption, newStatus);
|
||||
}
|
||||
|
||||
// Extract status change logic into reusable function
|
||||
function performStatusChange(statusSelect, selectedOption, newStatus) {
|
||||
// Extract status change logic into reusable function.
|
||||
// `comment` (optional) is included in the update_ticket payload so requires_comment
|
||||
// transitions pass server validation. lt.ticketStatus.submit handles the
|
||||
// comment-aware retry if a comment is required but was not pre-collected.
|
||||
function performStatusChange(statusSelect, selectedOption, newStatus, comment) {
|
||||
const ticketId = getTicketIdFromUrl();
|
||||
|
||||
if (!ticketId) {
|
||||
@@ -561,10 +559,10 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update status via API
|
||||
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
|
||||
// Update status via the shared comment-aware helper
|
||||
lt.ticketStatus.submit(ticketId, newStatus, { comment: comment })
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
if (data && data.success) {
|
||||
// Update the dropdown to show new status as current (preserve TDS v1.2 classes)
|
||||
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
|
||||
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
|
||||
@@ -582,12 +580,14 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
lt.toast.error('Error updating status: ' + (data.error || 'Unknown error'));
|
||||
lt.toast.error('Error updating status: ' + ((data && data.error) || 'Unknown error'));
|
||||
// Reset to current status
|
||||
statusSelect.selectedIndex = 0;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// User cancelled the required-comment modal — silently revert the dropdown
|
||||
if (error && error.cancelled) { statusSelect.selectedIndex = 0; return; }
|
||||
lt.toast.error('Error updating status: ' + error.message);
|
||||
// Reset to current status
|
||||
statusSelect.selectedIndex = 0;
|
||||
@@ -938,6 +938,8 @@ function handleFileUpload(files) {
|
||||
if (xhr.status === 200 || xhr.status === 201) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
// Keep the CSRF token in sync if the server rotated it
|
||||
if (response.csrf_token) window.CSRF_TOKEN = response.csrf_token;
|
||||
if (response.success) {
|
||||
if (uploadedCount === totalFiles) {
|
||||
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
|
||||
@@ -968,6 +970,9 @@ function handleFileUpload(files) {
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/upload_attachment.php');
|
||||
// Send CSRF via header to match the rest of the app (endpoint accepts both
|
||||
// the X-CSRF-Token header and the csrf_token form field).
|
||||
if (window.CSRF_TOKEN) xhr.setRequestHeader('X-CSRF-Token', window.CSRF_TOKEN);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
@@ -1142,12 +1147,17 @@ function handleMentionInput(e) {
|
||||
const text = textarea.value;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
|
||||
// Find @ symbol before cursor
|
||||
// Find @ symbol before cursor. Only trigger when the @ is at a word boundary
|
||||
// (start of input or preceded by whitespace) so it does not fire inside email
|
||||
// addresses like foo@bar.
|
||||
let atPos = -1;
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
const char = text[i];
|
||||
if (char === '@') {
|
||||
atPos = i;
|
||||
const prev = i > 0 ? text[i - 1] : '';
|
||||
if (i === 0 || /\s/.test(prev)) {
|
||||
atPos = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (char === ' ' || char === '\n') {
|
||||
@@ -1277,20 +1287,27 @@ function selectMention(username) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight mentions in comment text
|
||||
* Highlight mentions in comment text.
|
||||
* Skips content inside existing anchor tags so URLs/emails that contain '@'
|
||||
* (e.g. auto-linked links or mailto:) are not corrupted or nested.
|
||||
*/
|
||||
function highlightMentions(text) {
|
||||
return text.replace(/@([a-zA-Z0-9_-]+)/g, '<span class="mention">$1</span>');
|
||||
return text.replace(/<a\b[^>]*>[\s\S]*?<\/a>|@[a-zA-Z0-9_-]+/gi, function (m) {
|
||||
if (m.charAt(0) === '<') return m; // leave anchor tags untouched
|
||||
return '<span class="mention">' + m.slice(1) + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize mention autocomplete when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initMentionAutocomplete();
|
||||
|
||||
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
|
||||
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements).
|
||||
// Idempotency guard: only process each element once so re-runs don't nest spans.
|
||||
document.querySelectorAll('.comment-text').forEach(el => {
|
||||
if (!el.hasAttribute('data-markdown')) {
|
||||
if (!el.hasAttribute('data-markdown') && !el.dataset.mentionsProcessed) {
|
||||
el.innerHTML = highlightMentions(el.innerHTML);
|
||||
el.dataset.mentionsProcessed = '1';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user