- Add comment edit/delete functionality (owner or admin can modify) - Add edit/delete buttons to comments in TicketView - Create update_comment.php and delete_comment.php API endpoints - Add updateComment() and deleteComment() methods to CommentModel - Show "(edited)" indicator on modified comments - Add migration script for updated_at column - Auto-link URLs in plain text comments (non-markdown) - Add markdown table support with proper HTML rendering - Preserve code blocks during markdown parsing - Fix mobile UI elements showing on desktop (add display:none defaults) - Add mobile styles for CreateTicketView form elements - Stack status-priority-row on mobile devices - Update cache busters to v20260124e - Update Claude.md and README.md documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
/**
|
|
* Simple Markdown Parser for Tinker Tickets
|
|
* Supports basic markdown formatting without external dependencies
|
|
*/
|
|
|
|
function parseMarkdown(markdown) {
|
|
if (!markdown) return '';
|
|
|
|
let html = markdown;
|
|
|
|
// Escape HTML first to prevent XSS
|
|
html = html.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);
|
|
|
|
// 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>');
|
|
|
|
// 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, ftp)
|
|
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
|
|
// Headers (# H1, ## H2, etc.)
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
|
|
// Lists
|
|
// Unordered lists (- item or * item)
|
|
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
|
|
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
|
|
|
// Ordered lists (1. item)
|
|
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
|
|
|
// 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);
|
|
});
|
|
|
|
// Wrap in paragraph if not already wrapped
|
|
if (!html.startsWith('<')) {
|
|
html = '<p>' + html + '</p>';
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* 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" onclick="toolbarBold('${textareaId}')" title="Bold (Ctrl+B)"><b>B</b></button>
|
|
<button type="button" onclick="toolbarItalic('${textareaId}')" title="Italic (Ctrl+I)"><i>I</i></button>
|
|
<button type="button" onclick="toolbarCode('${textareaId}')" title="Code"></></button>
|
|
<span class="toolbar-separator"></span>
|
|
<button type="button" onclick="toolbarHeading('${textareaId}')" title="Heading">H</button>
|
|
<button type="button" onclick="toolbarList('${textareaId}')" title="List">≡</button>
|
|
<button type="button" onclick="toolbarQuote('${textareaId}')" title="Quote">"</button>
|
|
<span class="toolbar-separator"></span>
|
|
<button type="button" onclick="toolbarLink('${textareaId}')" title="Link">🔗</button>
|
|
`;
|
|
|
|
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';
|
|
});
|
|
}
|
|
|
|
// Run on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
processPlainTextComments();
|
|
});
|
|
|
|
// Expose for manual use
|
|
window.autoLinkUrls = autoLinkUrls;
|
|
window.processPlainTextComments = processPlainTextComments;
|