mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
kanban: full markdown rendering for task description and comments
- Rewrote _kanbanRenderMarkdown() from basic paragraph wrapper to a line-by-line block processor supporting headings, code blocks, lists, task lists, tables, blockquotes, horizontal rules, and strikethrough. - Added CSS for all new elements (table borders, code blocks, checkboxes, blockquote accent, heading sizing, etc.). - Dropped white-space: pre-wrap from .kanban-task-preview-body and .kanban-detail-row-main since markdown now handles layout. - Applied _kanbanRenderMarkdown() to task description (was esc()) and comment body (was esc()) in the task detail view.
This commit is contained in:
committed by
nesquena-hermes
parent
073bd3e1e2
commit
7983e025c4
+175
-4
@@ -1274,17 +1274,188 @@ function _kanbanRenderSidebar(columns){
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render inline markdown (bold, italic, code, links, strikethrough).
|
||||
* Input is already HTML-escaped.
|
||||
*/
|
||||
function _kanbanRenderMarkdownInline(escaped){
|
||||
return String(escaped || '')
|
||||
.replace(/~~([^~\n]+)~~/g, (_m, text) => `<del>${text}</del>`)
|
||||
.replace(/`([^`\n]+)`/g, (_m, code) => `<code>${code}</code>`)
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, (_m, text) => `<strong>${text}</strong>`)
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
|
||||
.replace(/(^|[^*a-zA-Z0-9])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
|
||||
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render full markdown block content: headings, code blocks, lists, tables,
|
||||
* task lists, blockquotes, horizontal rules, paragraphs + inline formatting.
|
||||
*/
|
||||
function _kanbanRenderMarkdown(source){
|
||||
if (!source) return '';
|
||||
return `<div class="hermes-kanban-md">${esc(source).split(/\r?\n/).map(line => line.trim() ? `<p>${_kanbanRenderMarkdownInline(line)}</p>` : '').join('')}</div>`;
|
||||
const lines = esc(source).split(/\r?\n/);
|
||||
const out = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// ── Code block ──
|
||||
if (/^```/.test(trimmed)) {
|
||||
const lang = trimmed.slice(3).trim();
|
||||
const codeLines = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^```/.test(lines[i].trim())) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // skip closing ```
|
||||
const codeHtml = codeLines.join('\n');
|
||||
out.push(lang
|
||||
? `<pre class="hermes-kanban-code"><code class="language-${_kanbanRenderMarkdownInline(lang)}">${codeHtml}</code></pre>`
|
||||
: `<pre class="hermes-kanban-code"><code>${codeHtml}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Horizontal rule ──
|
||||
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
|
||||
out.push('<hr>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Heading ──
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
out.push(`<h${level}>${_kanbanRenderMarkdownInline(headingMatch[2])}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Blockquote ──
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
const quoteLines = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''));
|
||||
i++;
|
||||
}
|
||||
out.push(`<blockquote>${_kanbanRenderMarkdownInline(quoteLines.join('<br>'))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Table row ──
|
||||
if (/^\|.+\|$/.test(trimmed)) {
|
||||
const tableRows = [];
|
||||
const tableAligns = [];
|
||||
while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) {
|
||||
const row = lines[i].trim();
|
||||
// Detect alignment separator row
|
||||
if (/^\|[\s:]*-{3,}[\s:]*\|/.test(row)) {
|
||||
const cells = row.split('|').filter(c => c.trim().length > 0);
|
||||
cells.forEach(c => {
|
||||
const t = c.trim();
|
||||
if (t.startsWith(':') && t.endsWith(':')) tableAligns.push('center');
|
||||
else if (t.endsWith(':')) tableAligns.push('right');
|
||||
else tableAligns.push('left');
|
||||
});
|
||||
} else {
|
||||
const cells = row.split('|').filter(c => c.trim().length > 0);
|
||||
tableRows.push(cells.map((c, ci) => {
|
||||
const align = tableAligns[ci] ? ` style="text-align:${tableAligns[ci]}"` : '';
|
||||
return `<td${align}>${_kanbanRenderMarkdownInline(c.trim())}</td>`;
|
||||
}).join(''));
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (tableRows.length) {
|
||||
out.push(`<table><tbody>${tableRows.map(r => `<tr>${r}</tr>`).join('')}</tbody></table>`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Task list item ──
|
||||
const taskMatch = trimmed.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
|
||||
if (taskMatch) {
|
||||
const checked = taskMatch[1] !== ' ';
|
||||
const text = taskMatch[2];
|
||||
const items = [];
|
||||
items.push(`<li class="hermes-kanban-task${checked ? ' checked' : ''}"><input type="checkbox"${checked ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(text)}</li>`);
|
||||
i++;
|
||||
// Collect continuation items
|
||||
while (i < lines.length) {
|
||||
const next = lines[i].trim();
|
||||
const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
|
||||
const nextLi = next.match(/^[-*+]\s+(.+)$/);
|
||||
if (nextTask) {
|
||||
const c = nextTask[1] !== ' ';
|
||||
items.push(`<li class="hermes-kanban-task${c ? ' checked' : ''}"><input type="checkbox"${c ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(nextTask[2])}</li>`);
|
||||
i++;
|
||||
} else if (nextLi) {
|
||||
items.push(`<li>${_kanbanRenderMarkdownInline(nextLi[1])}</li>`);
|
||||
i++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(`<ul>${items.join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Unordered list item ──
|
||||
const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/);
|
||||
if (ulMatch) {
|
||||
const items = [];
|
||||
items.push(`<li>${_kanbanRenderMarkdownInline(ulMatch[1])}</li>`);
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const next = lines[i].trim();
|
||||
const nextUl = next.match(/^[-*+]\s+(.+)$/);
|
||||
const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
|
||||
if (nextTask) break; // let task list handler get it
|
||||
if (nextUl) {
|
||||
items.push(`<li>${_kanbanRenderMarkdownInline(nextUl[1])}</li>`);
|
||||
i++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(`<ul>${items.join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Ordered list item ──
|
||||
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
|
||||
if (olMatch) {
|
||||
const items = [];
|
||||
items.push(`<li>${_kanbanRenderMarkdownInline(olMatch[1])}</li>`);
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const next = lines[i].trim();
|
||||
const nextOl = next.match(/^\d+\.\s+(.+)$/);
|
||||
if (nextOl) {
|
||||
items.push(`<li>${_kanbanRenderMarkdownInline(nextOl[1])}</li>`);
|
||||
i++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(`<ol>${items.join('')}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Empty line ──
|
||||
if (!trimmed) {
|
||||
out.push('');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Paragraph ──
|
||||
out.push(`<p>${_kanbanRenderMarkdownInline(trimmed)}</p>`);
|
||||
i++;
|
||||
}
|
||||
return `<div class="hermes-kanban-md">${out.join('\n')}</div>`;
|
||||
}
|
||||
|
||||
function _kanbanFormatDuration(seconds){
|
||||
@@ -1851,7 +2022,7 @@ function _kanbanCommentHtml(comment){
|
||||
const by = comment.author || comment.created_by || comment.actor || '';
|
||||
const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || '');
|
||||
return `<div class="kanban-detail-row">
|
||||
<div class="kanban-detail-row-main">${esc(body)}</div>
|
||||
<div class="kanban-detail-row-main">${_kanbanRenderMarkdown(body)}</div>
|
||||
<div class="kanban-detail-row-meta">${esc([by, at].filter(Boolean).join(' · '))}</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -2403,7 +2574,7 @@ function _kanbanRenderTaskDetail(data){
|
||||
<div class="kanban-task-preview-title">${esc(title)}</div>
|
||||
<button class="btn secondary kanban-edit-btn" onclick="openKanbanEdit('${esc(task.id)}')" data-i18n="kanban_edit_task" title="${esc(t('kanban_edit_task') || 'Edit task')}">${esc(t('kanban_edit_task') || 'Edit task')}</button>
|
||||
</div>
|
||||
<div class="kanban-task-preview-body">${esc(body)}</div>
|
||||
<div class="kanban-task-preview-body">${_kanbanRenderMarkdown(body)}</div>
|
||||
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
|
||||
<div class="kanban-status-actions">${statusButtons}</div>
|
||||
<div class="kanban-detail-grid">
|
||||
|
||||
+3
-3
@@ -4221,7 +4221,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-run-dispatch-btn:hover{
|
||||
background:color-mix(in srgb,var(--accent,#FFD700) 24%,transparent);
|
||||
}
|
||||
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
|
||||
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:6px;}
|
||||
.kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;}
|
||||
.kanban-status-actions .btn{font-size:11px;padding:4px 8px;}
|
||||
/* Generic styled buttons used throughout the Kanban panel. The Kanban PR
|
||||
@@ -4291,7 +4291,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;}
|
||||
.kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);}
|
||||
.kanban-detail-row:first-of-type{border-top:0;padding-top:0;}
|
||||
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;}
|
||||
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;}.kanban-detail-row-main .hermes-kanban-md p:last-child{margin-bottom:0;}
|
||||
.kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;}
|
||||
.kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);}
|
||||
.kanban-detail-empty{font-size:12px;color:var(--muted);}
|
||||
@@ -4323,7 +4323,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-card-stale-amber{border-color:rgba(245,197,66,.55)}
|
||||
.kanban-card-stale-red{border-color:rgba(255,95,95,.65)}
|
||||
.kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}
|
||||
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}.hermes-kanban-md h1,.hermes-kanban-md h2,.hermes-kanban-md h3,.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{margin:10px 0 4px;font-weight:650;color:var(--text)}.hermes-kanban-md h1{font-size:15px}.hermes-kanban-md h2{font-size:14px}.hermes-kanban-md h3{font-size:13px}.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{font-size:12px}.hermes-kanban-md ul,.hermes-kanban-md ol{margin:4px 0;padding-left:20px}.hermes-kanban-md li{margin:2px 0}.hermes-kanban-md li.checked{opacity:.6}.hermes-kanban-md li input[type=checkbox]{margin:0 4px 0 0;vertical-align:middle}.hermes-kanban-md table{border-collapse:collapse;margin:6px 0;font-size:11px;width:100%}.hermes-kanban-md td{border:1px solid var(--border);padding:4px 6px;vertical-align:top}.hermes-kanban-md blockquote{margin:4px 0;padding:2px 8px;border-left:3px solid var(--accent);color:var(--muted);font-size:12px}.hermes-kanban-md pre{background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0;overflow-x:auto;font-size:11px;line-height:1.4;color:var(--text)}.hermes-kanban-md hr{border:none;border-top:1px solid var(--border);margin:8px 0}
|
||||
|
||||
@media (max-width: 640px){
|
||||
.kanban-board{scroll-snap-type:x mandatory;}
|
||||
|
||||
Reference in New Issue
Block a user