Verified, high-confidence fixes from a project-wide review: - markdown.js: escape " and ' in the HTML-escape step. User-controlled image/link URLs and alt text were interpolated into "..." attributes without quote escaping, allowing attribute breakout and injected event handlers (stored XSS, only mitigated by CSP). Flagged independently by two reviewers. - cron/create_recurring_tickets.php & cron/cleanup_ratelimit.php: a mangled crontab example inside the docblock contained */ which closed the comment early, causing a fatal parse error — both cron jobs never ran. Rewrote the docblocks without a literal */. - update_ticket.php: validate visibility BEFORE the core DB write so an invalid payload can't leave the ticket updated while the request reports failure (which also skipped the audit delta and stats cache invalidation). - watch_ticket.php: GET watcher_count was capped at 6 (count of a LIMIT 6 list); use an unbounded COUNT(*) so it matches the POST path. - notifications.php: "assigned to me" LIKE pattern lacked a trailing delimiter, so user 12 also matched 120/123/etc.; anchor with }. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
574 lines
21 KiB
JavaScript
574 lines
21 KiB
JavaScript
/**
|
||
* Simple Markdown Parser for Tinker Tickets
|
||
* Supports basic markdown formatting without external dependencies
|
||
*/
|
||
|
||
function parseMarkdown(markdown) {
|
||
if (!markdown) return '';
|
||
|
||
// Footnotes — collect definitions and mark references with placeholders
|
||
// (must happen before HTML escaping so <sup> tags don't get escaped)
|
||
const footnotes = {};
|
||
const footnoteOrder = [];
|
||
const fnRefs = [];
|
||
markdown = markdown.replace(/^\[\^([^\]]+)\]:\s+(.+)$/gm, function(_, label, text) {
|
||
footnotes[label] = text;
|
||
return '';
|
||
});
|
||
markdown = markdown.replace(/\[\^([^\]]+)\]/g, function(_, label) {
|
||
if (!footnotes[label]) return '[^' + label + ']';
|
||
if (!footnoteOrder.includes(label)) footnoteOrder.push(label);
|
||
const n = footnoteOrder.indexOf(label) + 1;
|
||
fnRefs.push({ label, n });
|
||
return '%%FNREF' + (fnRefs.length - 1) + '%%';
|
||
});
|
||
|
||
let html = markdown;
|
||
|
||
// Escape HTML first to prevent XSS. Quotes MUST be escaped too: user-controlled
|
||
// text (e.g. image/link URLs and alt text) is later interpolated into "..."
|
||
// attributes, so an unescaped " would break out and inject event handlers.
|
||
html = html.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.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) {
|
||
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
|
||
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
|
||
});
|
||
|
||
// Inline code (`code`) - preserve and don't process further
|
||
const inlineCodes = [];
|
||
html = html.replace(/`([^`]+)`/g, function(match, code) {
|
||
inlineCodes.push('<code class="inline-code">' + code + '</code>');
|
||
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
|
||
});
|
||
|
||
// Tables (must be processed before other block elements)
|
||
html = parseMarkdownTables(html);
|
||
|
||
// Emoji :name: — common set
|
||
html = replaceEmoji(html);
|
||
|
||
// Bold (**text** or __text__)
|
||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||
|
||
// Italic (*text* or _text_)
|
||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||
|
||
// Strikethrough (~~text~~) — must run before subscript (~)
|
||
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||
|
||
// Highlight (==text==)
|
||
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
|
||
|
||
// Subscript H~2~O — single tilde (not preceded/followed by another tilde)
|
||
html = html.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '<sub>$1</sub>');
|
||
|
||
// Superscript X^2^ — caret pair
|
||
html = html.replace(/\^([^\^\n]+?)\^/g, '<sup>$1</sup>');
|
||
|
||
// Images  - must come before link handler
|
||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) {
|
||
if (/^https?:/i.test(url)) {
|
||
return '<img src="' + url + '" alt="' + alt + '" class="md-image" loading="lazy">';
|
||
}
|
||
return match;
|
||
});
|
||
|
||
// Links [text](url) - only allow safe protocols
|
||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
|
||
// Only allow http, https, mailto protocols
|
||
if (/^(https?:|mailto:|\/)/i.test(url)) {
|
||
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
|
||
}
|
||
// Block potentially dangerous protocols (javascript:, data:, etc.)
|
||
return text;
|
||
});
|
||
|
||
// Auto-link bare URLs (http, https)
|
||
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||
|
||
// Headings with optional {#id} anchor — ### My Heading {#my-id}
|
||
html = html.replace(/^(#{1,6})\s+(.+?)\s*(?:\{#([a-z0-9_-]+)\})?$/gm, function(match, hashes, text, id) {
|
||
const level = hashes.length;
|
||
const idAttr = id ? ' id="' + id + '"' : '';
|
||
return '<h' + level + idAttr + '>' + text + '</h' + level + '>';
|
||
});
|
||
|
||
// Lists — tag each item type with a placeholder, then wrap consecutive runs
|
||
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '%%OLI%%$1');
|
||
html = html.replace(/^\s*[-*+]\s+\[x\]\s+(.+)$/gim, '%%TDI%%$1');
|
||
html = html.replace(/^\s*[-*+]\s+\[ \]\s+(.+)$/gm, '%%TTI%%$1');
|
||
html = html.replace(/^\s*[-*+]\s+(.+)$/gm, '%%ULI%%$1');
|
||
|
||
// Wrap consecutive ordered items in <ol>
|
||
html = html.replace(/(%%OLI%%.+(\n%%OLI%%.+)*)/g, function(block) {
|
||
return '<ol>' + block.replace(/%%OLI%%(.+)/g, '<li>$1</li>') + '</ol>';
|
||
});
|
||
|
||
// Wrap consecutive unordered/task items in <ul>
|
||
html = html.replace(/((?:%%(?:ULI|TDI|TTI)%%).+(?:\n(?:%%(?:ULI|TDI|TTI)%%).+)*)/g, function(block) {
|
||
return '<ul>' + block
|
||
.replace(/%%ULI%%(.+)/g, '<li>$1</li>')
|
||
.replace(/%%TDI%%(.+)/g, '<li class="task-item task-done"><span class="task-cb">☑</span> $1</li>')
|
||
.replace(/%%TTI%%(.+)/g, '<li class="task-item task-todo"><span class="task-cb">☐</span> $1</li>')
|
||
+ '</ul>';
|
||
});
|
||
|
||
// Blockquotes (> text)
|
||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||
|
||
// Horizontal rules (--- or ***)
|
||
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
||
|
||
// Line breaks (two spaces at end of line or double newline)
|
||
html = html.replace(/ \n/g, '<br>');
|
||
html = html.replace(/\n\n/g, '</p><p>');
|
||
|
||
// Restore code blocks and inline code
|
||
codeBlocks.forEach((block, i) => {
|
||
html = html.replace('%%CODEBLOCK' + i + '%%', block);
|
||
});
|
||
inlineCodes.forEach((code, i) => {
|
||
html = html.replace('%%INLINECODE' + i + '%%', code);
|
||
});
|
||
|
||
// Restore footnote reference placeholders
|
||
fnRefs.forEach(function(ref, i) {
|
||
html = html.replace('%%FNREF' + i + '%%',
|
||
'<sup class="fn-ref"><a href="#fn-' + ref.label + '" id="fnref-' + ref.label + '">[' + ref.n + ']</a></sup>');
|
||
});
|
||
|
||
// Wrap in paragraph if not already wrapped
|
||
if (!html.startsWith('<')) {
|
||
html = '<p>' + html + '</p>';
|
||
}
|
||
|
||
// Append footnote definitions block
|
||
if (footnoteOrder.length) {
|
||
html += '<hr class="fn-hr"><ol class="fn-list">';
|
||
footnoteOrder.forEach(function(label, i) {
|
||
html += '<li id="fn-' + label + '" class="fn-item">' +
|
||
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
|
||
' <a href="#fnref-' + label + '" class="fn-back">↩</a></li>';
|
||
});
|
||
html += '</ol>';
|
||
}
|
||
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Replace :emoji: shortcodes with Unicode characters
|
||
*/
|
||
const _emojiMap = {
|
||
// Faces & emotions
|
||
smile: '😊', grin: '😁', joy: '😂', rofl: '🤣', smiley: '😃', sweat_smile: '😅',
|
||
wink: '😉', blush: '😊', heart_eyes: '😍', kissing: '😗', thinking: '🤔',
|
||
raised_eyebrow: '🤨', neutral_face: '😐', expressionless: '😑', unamused: '😒',
|
||
roll_eyes: '🙄', pensive: '😔', confused: '😕', worried: '😟', cry: '😢',
|
||
sob: '😭', scream: '😱', angry: '😠', rage: '😡', skull: '💀', sunglasses: '😎',
|
||
nerd: '🤓', monocle: '🧐', clown: '🤡', ghost: '👻', robot: '🤖', alien: '👽',
|
||
// Hands & people
|
||
thumbsup: '👍', '+1': '👍', thumbsdown: '👎', '-1': '👎', clap: '👏',
|
||
wave: '👋', raised_hands: '🙌', pray: '🙏', point_up: '☝️', point_right: '👉',
|
||
point_left: '👈', point_down: '👇', fist: '✊', punch: '👊', v: '✌️', ok_hand: '👌',
|
||
muscle: '💪', eyes: '👀', eye: '👁️', ear: '👂', brain: '🧠', man: '👨', woman: '👩',
|
||
// Hearts & symbols
|
||
heart: '❤️', orange_heart: '🧡', yellow_heart: '💛', green_heart: '💚',
|
||
blue_heart: '💙', purple_heart: '💜', black_heart: '🖤', broken_heart: '💔',
|
||
star: '⭐', star2: '🌟', sparkles: '✨', fire: '🔥', boom: '💥', zap: '⚡',
|
||
check: '✅', white_check_mark: '✅', x: '❌', heavy_check_mark: '✔️',
|
||
warning: '⚠️', no_entry: '⛔', stop_sign: '🛑', prohibited: '🚫',
|
||
question: '❓', exclamation: '❗', grey_question: '❔', grey_exclamation: '❕',
|
||
100: '💯', tada: '🎉', confetti_ball: '🎊', trophy: '🏆', medal: '🥇',
|
||
// Tech & work
|
||
bug: '🐛', rocket: '🚀', computer: '💻', keyboard: '⌨️', mouse: '🖱️',
|
||
printer: '🖨️', phone: '📱', email: '📧', inbox_tray: '📥', outbox_tray: '📤',
|
||
memo: '📝', pencil: '✏️', pen: '🖊️', paperclip: '📎', link: '🔗',
|
||
hammer: '🔨', wrench: '🔧', gear: '⚙️', lock: '🔒', unlock: '🔓',
|
||
key: '🔑', mag: '🔍', bar_chart: '📊', chart_increasing: '📈', chart_decreasing: '📉',
|
||
clipboard: '📋', calendar: '📅', clock: '🕐', hourglass: '⏳', bell: '🔔',
|
||
mute: '🔇', loud_sound: '🔊', bulb: '💡', battery: '🔋', electric_plug: '🔌',
|
||
recycle: '♻️', package: '📦', label: '🏷️', bookmark: '🔖', flag: '🚩',
|
||
// Nature & misc
|
||
sun: '☀️', moon: '🌙', cloud: '☁️', snowflake: '❄️', umbrella: '☂️',
|
||
dog: '🐶', cat: '🐱', pizza: '🍕', coffee: '☕', beer: '🍺',
|
||
white_flag: '🏳️', checkered_flag: '🏁', construction: '🚧', sos: '🆘',
|
||
info: 'ℹ️', new: '🆕', free: '🆓', cool: '🆒', up: '🆙', soon: '🔜',
|
||
};
|
||
|
||
function replaceEmoji(text) {
|
||
return text.replace(/:([a-z0-9_+\-]+):/g, function(match, name) {
|
||
return _emojiMap[name] || match;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Parse markdown tables
|
||
* Supports: | Header | Header |
|
||
* |--------|--------|
|
||
* | Cell | Cell |
|
||
*/
|
||
function parseMarkdownTables(html) {
|
||
const lines = html.split('\n');
|
||
const result = [];
|
||
let inTable = false;
|
||
let tableRows = [];
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim();
|
||
|
||
// Check if line is a table row (starts and ends with |, or has | in the middle)
|
||
if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) {
|
||
// Check if next line is separator (|---|---|)
|
||
const nextLine = lines[i + 1] ? lines[i + 1].trim() : '';
|
||
const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/);
|
||
|
||
if (!inTable && !isSeparator) {
|
||
// Start of table - check if this is a header row
|
||
if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) {
|
||
inTable = true;
|
||
tableRows.push({ type: 'header', content: line });
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (inTable) {
|
||
if (isSeparator) {
|
||
// Skip separator line
|
||
continue;
|
||
}
|
||
tableRows.push({ type: 'body', content: line });
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Not a table row - flush any accumulated table
|
||
if (inTable && tableRows.length > 0) {
|
||
result.push(buildTable(tableRows));
|
||
tableRows = [];
|
||
inTable = false;
|
||
}
|
||
result.push(lines[i]);
|
||
}
|
||
|
||
// Flush remaining table
|
||
if (tableRows.length > 0) {
|
||
result.push(buildTable(tableRows));
|
||
}
|
||
|
||
return result.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Build HTML table from parsed rows
|
||
*/
|
||
function buildTable(rows) {
|
||
if (rows.length === 0) return '';
|
||
|
||
let html = '<table class="markdown-table">';
|
||
|
||
rows.forEach((row, index) => {
|
||
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' : '');
|
||
|
||
if (wrapper === 'thead') html += '<thead>';
|
||
if (wrapper === 'tbody') html += '<tbody>';
|
||
|
||
html += '<tr>';
|
||
cells.forEach(cell => {
|
||
html += `<${tag}>${cell.trim()}</${tag}>`;
|
||
});
|
||
html += '</tr>';
|
||
|
||
if (row.type === 'header') html += '</thead>';
|
||
});
|
||
|
||
html += '</tbody></table>';
|
||
return html;
|
||
}
|
||
|
||
// Apply markdown rendering to all elements with data-markdown attribute
|
||
function renderMarkdownElements() {
|
||
document.querySelectorAll('[data-markdown]').forEach(element => {
|
||
const markdownText = element.getAttribute('data-markdown') || element.textContent;
|
||
element.innerHTML = parseMarkdown(markdownText);
|
||
});
|
||
}
|
||
|
||
// Apply markdown to description and comments on page load
|
||
document.addEventListener('DOMContentLoaded', renderMarkdownElements);
|
||
|
||
// Expose for manual use
|
||
window.parseMarkdown = parseMarkdown;
|
||
window.renderMarkdownElements = renderMarkdownElements;
|
||
|
||
// ========================================
|
||
// Rich Text Editor Toolbar Functions
|
||
// ========================================
|
||
|
||
/**
|
||
* Insert markdown formatting around selection
|
||
*/
|
||
function insertMarkdownFormat(textareaId, prefix, suffix) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return;
|
||
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const selectedText = text.substring(start, end);
|
||
|
||
// Insert formatting
|
||
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
||
textarea.value = newText;
|
||
|
||
// Set cursor position
|
||
if (selectedText) {
|
||
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
||
} else {
|
||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||
}
|
||
|
||
textarea.focus();
|
||
|
||
// Trigger input event to update preview if enabled
|
||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
/**
|
||
* Insert markdown at cursor position
|
||
*/
|
||
function insertMarkdownText(textareaId, text) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return;
|
||
|
||
const start = textarea.selectionStart;
|
||
const value = textarea.value;
|
||
|
||
textarea.value = value.substring(0, start) + text + value.substring(start);
|
||
textarea.setSelectionRange(start + text.length, start + text.length);
|
||
textarea.focus();
|
||
|
||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
/**
|
||
* Toolbar button handlers
|
||
*/
|
||
function toolbarBold(textareaId) {
|
||
insertMarkdownFormat(textareaId, '**', '**');
|
||
}
|
||
|
||
function toolbarItalic(textareaId) {
|
||
insertMarkdownFormat(textareaId, '_', '_');
|
||
}
|
||
|
||
function toolbarCode(textareaId) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return;
|
||
|
||
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||
|
||
// Use code block for multi-line, inline code for single line
|
||
if (selectedText.includes('\n')) {
|
||
insertMarkdownFormat(textareaId, '```\n', '\n```');
|
||
} else {
|
||
insertMarkdownFormat(textareaId, '`', '`');
|
||
}
|
||
}
|
||
|
||
function toolbarLink(textareaId) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return;
|
||
|
||
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||
|
||
if (selectedText) {
|
||
// Wrap selected text as link text
|
||
insertMarkdownFormat(textareaId, '[', '](url)');
|
||
} else {
|
||
insertMarkdownText(textareaId, '[link text](url)');
|
||
}
|
||
}
|
||
|
||
function toolbarList(textareaId) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return;
|
||
|
||
const start = textarea.selectionStart;
|
||
const text = textarea.value;
|
||
|
||
// Find start of current line
|
||
let lineStart = start;
|
||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||
lineStart--;
|
||
}
|
||
|
||
// Insert list marker at beginning of line
|
||
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
||
textarea.setSelectionRange(start + 2, start + 2);
|
||
textarea.focus();
|
||
|
||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
function toolbarHeading(textareaId) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return;
|
||
|
||
const start = textarea.selectionStart;
|
||
const text = textarea.value;
|
||
|
||
// Find start of current line
|
||
let lineStart = start;
|
||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||
lineStart--;
|
||
}
|
||
|
||
// Insert heading marker at beginning of line
|
||
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
||
textarea.setSelectionRange(start + 3, start + 3);
|
||
textarea.focus();
|
||
|
||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
function toolbarQuote(textareaId) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return;
|
||
|
||
const start = textarea.selectionStart;
|
||
const text = textarea.value;
|
||
|
||
// Find start of current line
|
||
let lineStart = start;
|
||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||
lineStart--;
|
||
}
|
||
|
||
// Insert quote marker at beginning of line
|
||
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
||
textarea.setSelectionRange(start + 2, start + 2);
|
||
textarea.focus();
|
||
|
||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
/**
|
||
* Create and insert toolbar HTML for a textarea
|
||
*/
|
||
function createEditorToolbar(textareaId, containerId) {
|
||
const container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
|
||
const toolbar = document.createElement('div');
|
||
toolbar.className = 'editor-toolbar';
|
||
toolbar.innerHTML = `
|
||
<button type="button" data-toolbar-action="bold" data-textarea="${textareaId}" title="Bold (Ctrl+B)"><b>B</b></button>
|
||
<button type="button" data-toolbar-action="italic" data-textarea="${textareaId}" title="Italic (Ctrl+I)"><i>I</i></button>
|
||
<button type="button" data-toolbar-action="code" data-textarea="${textareaId}" title="Code"></></button>
|
||
<span class="toolbar-separator"></span>
|
||
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
|
||
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||
<span class="toolbar-separator"></span>
|
||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
||
`;
|
||
|
||
// Add event delegation for toolbar buttons
|
||
toolbar.addEventListener('click', function(e) {
|
||
const btn = e.target.closest('[data-toolbar-action]');
|
||
if (!btn) return;
|
||
|
||
const action = btn.dataset.toolbarAction;
|
||
const targetId = btn.dataset.textarea;
|
||
|
||
switch (action) {
|
||
case 'bold': toolbarBold(targetId); break;
|
||
case 'italic': toolbarItalic(targetId); break;
|
||
case 'code': toolbarCode(targetId); break;
|
||
case 'heading': toolbarHeading(targetId); break;
|
||
case 'list': toolbarList(targetId); break;
|
||
case 'quote': toolbarQuote(targetId); break;
|
||
case 'link': toolbarLink(targetId); break;
|
||
}
|
||
});
|
||
|
||
container.insertBefore(toolbar, container.firstChild);
|
||
}
|
||
|
||
// Expose toolbar functions globally
|
||
window.toolbarBold = toolbarBold;
|
||
window.toolbarItalic = toolbarItalic;
|
||
window.toolbarCode = toolbarCode;
|
||
window.toolbarLink = toolbarLink;
|
||
window.toolbarList = toolbarList;
|
||
window.toolbarHeading = toolbarHeading;
|
||
window.toolbarQuote = toolbarQuote;
|
||
window.createEditorToolbar = createEditorToolbar;
|
||
window.insertMarkdownFormat = insertMarkdownFormat;
|
||
window.insertMarkdownText = insertMarkdownText;
|
||
|
||
// ========================================
|
||
// Auto-link URLs in plain text (non-markdown)
|
||
// ========================================
|
||
|
||
/**
|
||
* Convert plain text URLs to clickable links
|
||
* Used for non-markdown comments
|
||
*/
|
||
function autoLinkUrls(text) {
|
||
if (!text) return '';
|
||
// Match URLs that aren't already in an href attribute
|
||
return text.replace(/(?<!["\'>])(https?:\/\/[^\s<>\[\]()]+)/g,
|
||
'<a href="$1" target="_blank" rel="noopener noreferrer" class="auto-link">$1</a>');
|
||
}
|
||
|
||
/**
|
||
* Process all non-markdown comment elements to auto-link URLs
|
||
*/
|
||
function processPlainTextComments() {
|
||
document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => {
|
||
// Only process if not already processed
|
||
if (element.dataset.linksProcessed) return;
|
||
element.innerHTML = autoLinkUrls(element.innerHTML);
|
||
element.dataset.linksProcessed = 'true';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Render all [data-markdown] comment elements that haven't been rendered yet
|
||
*/
|
||
function renderMarkdownComments() {
|
||
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
|
||
el.classList.add('lt-markdown');
|
||
el.innerHTML = parseMarkdown(el.textContent);
|
||
el.dataset.rendered = '1';
|
||
});
|
||
}
|
||
|
||
// Run on page load
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
renderMarkdownComments();
|
||
processPlainTextComments();
|
||
});
|
||
|
||
window.renderMarkdownComments = renderMarkdownComments;
|
||
|
||
// Expose for manual use
|
||
window.autoLinkUrls = autoLinkUrls;
|
||
window.processPlainTextComments = processPlainTextComments;
|