diff --git a/README.md b/README.md index 696600a..84597b6 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,15 @@ A versatile ComfyUI custom node that renders HTML, SVG, Markdown, and code conte ## Features ### Content Rendering -- **Auto-detection** of content type: HTML, SVG, Markdown, Python, JavaScript, CSS, or plain text +- **Auto-detection** of content type: HTML, SVG, Markdown, JSON, YAML, CSV, Python, JavaScript, CSS, Terminal (ANSI), or plain text - **SVG support** - Renders SVG graphics centered in the viewport -- **Markdown parsing** - Full support for headers, lists, links, images, code blocks, tables, and emphasis -- **Code formatting** - Code content displayed with monospace font in styled blocks +- **Markdown parsing** - Full support for headers, lists, links, images, code blocks, tables, emphasis, and Mermaid diagrams +- **JSON viewer** - Collapsible tree view with syntax highlighting +- **CSV/Tables** - Automatic table rendering with styled headers +- **YAML** - Syntax highlighted display +- **LaTeX/Math** - Renders mathematical formulas using KaTeX (`$$...$$` or `$...$`) +- **Terminal output** - Parses ANSI escape codes for colored terminal logs +- **Code formatting** - Code content displayed with monospace font and Prism.js syntax highlighting - **ComfyUI theme integration** - Automatically matches ComfyUI's theme colors ### List Support diff --git a/fonts/KaTeX_Main-Bold.woff2 b/fonts/KaTeX_Main-Bold.woff2 new file mode 100644 index 0000000..ab2ad21 Binary files /dev/null and b/fonts/KaTeX_Main-Bold.woff2 differ diff --git a/fonts/KaTeX_Main-Italic.woff2 b/fonts/KaTeX_Main-Italic.woff2 new file mode 100644 index 0000000..b50920e Binary files /dev/null and b/fonts/KaTeX_Main-Italic.woff2 differ diff --git a/fonts/KaTeX_Main-Regular.woff2 b/fonts/KaTeX_Main-Regular.woff2 new file mode 100644 index 0000000..eb24a7b Binary files /dev/null and b/fonts/KaTeX_Main-Regular.woff2 differ diff --git a/fonts/KaTeX_Math-Italic.woff2 b/fonts/KaTeX_Math-Italic.woff2 new file mode 100644 index 0000000..215c143 Binary files /dev/null and b/fonts/KaTeX_Math-Italic.woff2 differ diff --git a/fonts/KaTeX_Size1-Regular.woff2 b/fonts/KaTeX_Size1-Regular.woff2 new file mode 100644 index 0000000..c5a8462 Binary files /dev/null and b/fonts/KaTeX_Size1-Regular.woff2 differ diff --git a/fonts/KaTeX_Size2-Regular.woff2 b/fonts/KaTeX_Size2-Regular.woff2 new file mode 100644 index 0000000..e1bccfe Binary files /dev/null and b/fonts/KaTeX_Size2-Regular.woff2 differ diff --git a/screenshots/screenshot_7.jpg b/screenshots/screenshot_7.jpg new file mode 100644 index 0000000..95c80df Binary files /dev/null and b/screenshots/screenshot_7.jpg differ diff --git a/screenshots/screenshot_8.jpg b/screenshots/screenshot_8.jpg new file mode 100644 index 0000000..c5257a9 Binary files /dev/null and b/screenshots/screenshot_8.jpg differ diff --git a/screenshots/screenshot_9.jpg b/screenshots/screenshot_9.jpg new file mode 100644 index 0000000..2dc1d98 Binary files /dev/null and b/screenshots/screenshot_9.jpg differ diff --git a/web/comfy_viewer.js b/web/comfy_viewer.js index 7b16904..404024a 100644 --- a/web/comfy_viewer.js +++ b/web/comfy_viewer.js @@ -14,6 +14,10 @@ const STATE = { cleanupListenersAttached: false, lastScale: 1, prismScripts: null, + mermaidScript: null, + katexCore: null, + katexAutoRender: null, + katexCss: null, }; const PRISM_FILES = [ @@ -26,6 +30,10 @@ const PRISM_FILES = [ "prism-bash.min.txt", ]; +const MERMAID_FILE = "mermaid.min.txt"; +const KATEX_FILES = ["katex.min.txt", "katex-auto-render.min.txt"]; +const KATEX_CSS_FILE = "katex-with-fonts.min.css.txt"; + async function loadPrismScripts() { if (STATE.prismScripts) return STATE.prismScripts; @@ -47,6 +55,61 @@ async function loadPrismScripts() { loadPrismScripts(); +async function loadMermaidScript() { + if (STATE.mermaidScript) return STATE.mermaidScript; + + try { + const basePath = import.meta.url.substring(0, import.meta.url.lastIndexOf("/")); + const res = await fetch(`${basePath}/${MERMAID_FILE}`); + STATE.mermaidScript = res.ok ? await res.text() : ""; + return STATE.mermaidScript; + } catch (e) { + console.error("[WAS Viewer] Failed to load Mermaid script:", e); + return ""; + } +} + +async function loadKatexScripts() { + if (STATE.katexCore && STATE.katexAutoRender && STATE.katexCss) return true; + + try { + const basePath = import.meta.url.substring(0, import.meta.url.lastIndexOf("/")); + const [katexCore, autoRender, katexCss] = await Promise.all([ + fetch(`${basePath}/${KATEX_FILES[0]}`).then(r => r.ok ? r.text() : ""), + fetch(`${basePath}/${KATEX_FILES[1]}`).then(r => r.ok ? r.text() : ""), + fetch(`${basePath}/${KATEX_CSS_FILE}`).then(r => r.ok ? r.text() : "") + ]); + STATE.katexCore = katexCore; + STATE.katexAutoRender = autoRender; + STATE.katexCss = katexCss; + return true; + } catch (e) { + console.error("[WAS Viewer] Failed to load KaTeX scripts:", e); + return false; + } +} + +function refreshAllViewers() { + for (const [nodeId, elements] of STATE.nodeIdToElements.entries()) { + if (elements && elements.lastContentHash) { + elements.lastContentHash = ""; + const node = app?.graph?.getNodeById(parseInt(nodeId)); + if (node) { + try { + updateIframeContent(node, elements); + } catch (e) {} + } + } + } +} + +loadMermaidScript().then(() => { + if (STATE.mermaidScript) refreshAllViewers(); +}); +loadKatexScripts().then(() => { + if (STATE.katexCore && STATE.katexCss) refreshAllViewers(); +}); + function simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -106,7 +169,43 @@ function detectContentType(content) { return "html"; } - const scores = { html: 0, markdown: 0, python: 0, javascript: 0, css: 0 }; + const scores = { html: 0, markdown: 0, python: 0, javascript: 0, css: 0, json: 0, yaml: 0, csv: 0, ansi: 0 }; + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + JSON.parse(trimmed); + return "json"; + } catch {} + } + + if (/\x1b\[[\d;]*m/.test(trimmed) || /\033\[[\d;]*m/.test(trimmed)) { + return "ansi"; + } + + const lines = trimmed.split("\n"); + const csvLines = lines.filter(l => l.includes(",") && !l.includes("{") && !l.includes("<")); + if (csvLines.length >= 2) { + const firstLineCommas = (lines[0].match(/,/g) || []).length; + if (firstLineCommas >= 1) { + const consistentCommas = csvLines.filter(l => (l.match(/,/g) || []).length === firstLineCommas).length; + if (consistentCommas >= csvLines.length * 0.8) { + scores.csv += 5; + } + } + } + + const yamlPatterns = [ + [/^[\w-]+:\s*.+$/m, 2], + [/^[\w-]+:\s*$/m, 2], + [/^\s+-\s+.+$/m, 1], + [/^---\s*$/m, 3], + ]; + for (const [pattern, weight] of yamlPatterns) { + if (pattern.test(trimmed)) scores.yaml += weight; + } + if (trimmed.includes(": ") && !trimmed.includes("{") && !trimmed.includes("<")) { + scores.yaml += 1; + } const htmlTags = ["", "", "", "", "", "", "
"];
@@ -198,21 +297,75 @@ function escapeHtml(text) {
   return div.innerHTML;
 }
 
+function parseNestedList(match, listTag, itemTag) {
+  const regex = new RegExp(`<${itemTag}[^>]*data-level="(\\d+)"[^>]*(?:class="([^"]*)")?[^>]*>(.*?)<\\/${itemTag}>`, 'g');
+  const items = [];
+  let m;
+  while ((m = regex.exec(match)) !== null) {
+    items.push({ level: parseInt(m[1]), className: m[2] || '', content: m[3] });
+  }
+  
+  if (items.length === 0) return match;
+  
+  let result = '';
+  let stack = [];
+  
+  for (const item of items) {
+    while (stack.length > 0 && stack[stack.length - 1] > item.level) {
+      result += ``;
+      stack.pop();
+    }
+    
+    if (stack.length === 0 || stack[stack.length - 1] < item.level) {
+      result += `<${listTag}>`;
+      stack.push(item.level);
+    } else if (stack[stack.length - 1] === item.level && result.length > 0) {
+      result += '';
+    }
+    
+    const classAttr = item.className ? ` class="${item.className}"` : '';
+    result += `${item.content}`;
+  }
+  
+  while (stack.length > 0) {
+    result += ``;
+    stack.pop();
+  }
+  
+  return result;
+}
+
 function parseMarkdown(md) {
   let html = md;
 
+  // Normalize mixed LaTeX delimiters: $$\(...\)$$ -> $$...$$ and $$\[...\]$$ -> $$...$$
+  html = html.replace(/\$\$\s*\\\(([^]*?)\\\)\s*\$\$/g, '$$$$$1$$$$');
+  html = html.replace(/\$\$\s*\\\[([^]*?)\\\]\s*\$\$/g, '$$$$$1$$$$');
+  
+  // Convert inline $$...$$ to $...$ when surrounded by text on same line (not at line start/end)
+  html = html.replace(/([^\n$])\$\$([^$\n]+)\$\$([^\n$])/g, '$1$$$2$$$3');
+
+  html = html.replace(/^```(?:mermaid|flow|flex)(?:\[([^\]]*)\])?\n([\s\S]*?)```$/gm, (_, options, code) => {
+    const centered = options && options.toLowerCase().includes('center');
+    const style = centered ? 'text-align:center;' : 'text-align:left;';
+    return `
${code.trim()}
`; + }); + html = html.replace(/^```(\w*)\n([\s\S]*?)```$/gm, (_, lang, code) => { return `
${escapeHtml(code.trim())}
`; }); html = html.replace(/`([^`]+)`/g, "$1"); - html = html.replace(/^######\s+(.*)$/gm, "
$1
"); - html = html.replace(/^#####\s+(.*)$/gm, "
$1
"); - html = html.replace(/^####\s+(.*)$/gm, "

$1

"); - html = html.replace(/^###\s+(.*)$/gm, "

$1

"); - html = html.replace(/^##\s+(.*)$/gm, "

$1

"); - html = html.replace(/^#\s+(.*)$/gm, "

$1

"); + html = html.replace(/^(.+)\n=+\s*$/gm, "

$1

"); + html = html.replace(/^(.+)\n-+\s*$/gm, "

$1

"); + + html = html.replace(/^######\s*(.+)$/gm, "
$1
"); + html = html.replace(/^#####\s*(.+)$/gm, "
$1
"); + html = html.replace(/^####\s*(.+)$/gm, "

$1

"); + html = html.replace(/^###\s*(.+)$/gm, "

$1

"); + html = html.replace(/^##\s*(.+)$/gm, "

$1

"); + html = html.replace(/^#\s*(.+)$/gm, "

$1

"); html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); @@ -225,14 +378,31 @@ function parseMarkdown(md) { html = html.replace(/^>\s+(.*)$/gm, "
$1
"); - html = html.replace(/^\d+\.\s+(.*)$/gm, "$1"); - html = html.replace(/(.*<\/oli>\n?)+/g, (match) => { - return "
    " + match.replace(/<\/?oli>/g, (tag) => tag.replace("oli", "li")) + "
"; + html = html.replace(/^(\s*)- \[x\]\s+(.*)$/gim, (_, indent, text) => { + const level = Math.floor(indent.length / 2); + return ` ${text}`; + }); + html = html.replace(/^(\s*)- \[ \]\s+(.*)$/gm, (_, indent, text) => { + const level = Math.floor(indent.length / 2); + return ` ${text}`; }); - html = html.replace(/^[-*+]\s+(.*)$/gm, "$1"); - html = html.replace(/(.*<\/uli>\n?)+/g, (match) => { - return "
    " + match.replace(/<\/?uli>/g, (tag) => tag.replace("uli", "li")) + "
"; + html = html.replace(/^(\s*)\d+\.\s+(.*)$/gm, (_, indent, text) => { + const level = Math.floor(indent.length / 2); + return `${text}`; + }); + + html = html.replace(/^(\s*)[-*+]\s+(.*)$/gm, (_, indent, text) => { + const level = Math.floor(indent.length / 2); + return `${text}`; + }); + + html = html.replace(/(]*>.*?<\/oli>\s*)+/g, (match) => { + return parseNestedList(match, 'ol', 'oli'); + }); + + html = html.replace(/(]*>.*?<\/uli>\s*)+/g, (match) => { + return parseNestedList(match, 'ul', 'uli'); }); html = html.replace(/^---$/gm, "
"); @@ -273,12 +443,108 @@ function renderCodeContent(content, language) { python: "python", javascript: "javascript", css: "css", + yaml: "yaml", text: "plaintext", }; const lang = langMap[language] || "plaintext"; return `
${escapeHtml(content)}
`; } +function renderJsonTree(data, depth = 0) { + const indent = " ".repeat(depth); + if (data === null) return `null`; + if (typeof data === "boolean") return `${data}`; + if (typeof data === "number") return `${data}`; + if (typeof data === "string") return `"${escapeHtml(data)}"`; + + if (Array.isArray(data)) { + if (data.length === 0) return `[]`; + const id = `json-${Math.random().toString(36).slice(2, 9)}`; + const items = data.map((item, i) => { + const comma = i < data.length - 1 ? "," : ""; + return `${indent} ${renderJsonTree(item, depth + 1)}${comma}`; + }).join("\n"); + return `[${data.length} items]
\n${items}\n${indent}]
`; + } + + if (typeof data === "object") { + const keys = Object.keys(data); + if (keys.length === 0) return `{}`; + const id = `json-${Math.random().toString(36).slice(2, 9)}`; + const items = keys.map((key, i) => { + const comma = i < keys.length - 1 ? "," : ""; + return `${indent} "${escapeHtml(key)}": ${renderJsonTree(data[key], depth + 1)}${comma}`; + }).join("\n"); + return `{${keys.length} keys}
\n${items}\n${indent}}
`; + } + + return escapeHtml(String(data)); +} + +function renderCsvTable(content) { + const lines = content.trim().split("\n"); + if (lines.length === 0) return `
${escapeHtml(content)}
`; + + const parseRow = (line) => { + const result = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === "," && !inQuotes) { + result.push(current.trim()); + current = ""; + } else { + current += char; + } + } + result.push(current.trim()); + return result; + }; + + const headers = parseRow(lines[0]); + const rows = lines.slice(1).map(parseRow); + + const thead = `${headers.map(h => `${escapeHtml(h)}`).join("")}`; + const tbody = `${rows.map(row => + `${row.map(cell => `${escapeHtml(cell)}`).join("")}` + ).join("")}`; + + return `
${thead}${tbody}
`; +} + +function renderAnsiContent(content) { + const ansiColors = { + "30": "#000", "31": "#e74c3c", "32": "#2ecc71", "33": "#f39c12", + "34": "#3498db", "35": "#9b59b6", "36": "#1abc9c", "37": "#ecf0f1", + "90": "#7f8c8d", "91": "#ff6b6b", "92": "#69db7c", "93": "#ffd43b", + "94": "#74c0fc", "95": "#da77f2", "96": "#63e6be", "97": "#fff", + }; + const bgColors = { + "40": "#000", "41": "#e74c3c", "42": "#2ecc71", "43": "#f39c12", + "44": "#3498db", "45": "#9b59b6", "46": "#1abc9c", "47": "#ecf0f1", + }; + + let html = escapeHtml(content); + html = html.replace(/(?:\x1b|\033)\[([\d;]*)m/g, (match, codes) => { + if (!codes || codes === "0") return ""; + const parts = codes.split(";"); + let style = ""; + for (const code of parts) { + if (ansiColors[code]) style += `color:${ansiColors[code]};`; + if (bgColors[code]) style += `background:${bgColors[code]};`; + if (code === "1") style += "font-weight:bold;"; + if (code === "3") style += "font-style:italic;"; + if (code === "4") style += "text-decoration:underline;"; + } + return style ? `` : ""; + }); + + return `
${html}
`; +} + async function createZipBlob(files) { const crc32 = (data) => { let crc = 0xFFFFFFFF; @@ -427,12 +693,15 @@ function buildIframeContent(content, contentType, theme, excluded = []) { margin: 16px 0; } h1, h2, h3, h4, h5, h6 { - margin-top: 20px; - margin-bottom: 10px; + margin-top: 16px; + margin-bottom: 8px; color: ${theme.fg}; } - ul, ol { padding-left: 24px; } - li { margin: 4px 0; } + h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child { + margin-top: 0; + } + ul, ol { padding-left: 24px; margin: 8px 0; } + li { margin: 2px 0; } .list-item { background: rgba(0,0,0,0.2); border: 1px solid ${theme.border}; @@ -501,6 +770,82 @@ function buildIframeContent(content, contentType, theme, excluded = []) { white-space: pre-wrap; word-wrap: break-word; } + .json-tree pre { + margin: 0; + white-space: pre-wrap; + font-size: 13px; + } + .json-key { color: #9cdcfe; } + .json-string { color: #ce9178; } + .json-number { color: #b5cea8; } + .json-boolean { color: #569cd6; } + .json-null { color: #569cd6; } + .json-bracket { color: ${theme.fg}; } + .json-toggle { + cursor: pointer; + user-select: none; + } + .json-toggle:hover { opacity: 0.8; } + .json-count { + color: #6a9955; + font-size: 11px; + margin-left: 4px; + } + .json-collapsible { + margin-left: 16px; + } + .json-collapsible.collapsed { + display: none; + } + .csv-container { + overflow-x: auto; + } + .csv-table { + border-collapse: collapse; + width: 100%; + font-size: 13px; + } + .csv-table th, .csv-table td { + border: 1px solid ${theme.border}; + padding: 8px 12px; + text-align: left; + } + .csv-table th { + background: rgba(0,0,0,0.3); + font-weight: bold; + } + .csv-table tr:nth-child(even) { + background: rgba(0,0,0,0.1); + } + .ansi-output { + background: #1e1e1e; + padding: 12px; + border-radius: 6px; + font-family: "Fira Code", Consolas, Monaco, monospace; + font-size: 13px; + line-height: 1.4; + } + .mermaid { + background: transparent; + text-align: center; + padding: 16px; + } + .math-block { + text-align: center; + margin: 16px 0; + overflow-x: auto; + } + .task-item { + list-style: none; + margin-left: -20px; + } + .task-item input[type="checkbox"] { + margin-right: 8px; + accent-color: ${theme.accent}; + } + .task-item.checked { + color: ${theme.fg}; + } `; const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n"; @@ -578,6 +923,23 @@ function buildIframeContent(content, contentType, theme, excluded = []) { case "svg": bodyContent = `
${content}
`; break; + case "json": + try { + const parsed = JSON.parse(content); + bodyContent = `
${renderJsonTree(parsed)}
`; + } catch { + bodyContent = renderCodeContent(content, "javascript"); + } + break; + case "csv": + bodyContent = renderCsvTable(content); + break; + case "yaml": + bodyContent = renderCodeContent(content, "yaml"); + break; + case "ansi": + bodyContent = renderAnsiContent(content); + break; case "python": case "javascript": case "css": @@ -628,7 +990,7 @@ function buildIframeContent(content, contentType, theme, excluded = []) { .token.italic { font-style: italic; } `; - const needsPrism = ["python", "javascript", "css"].includes(contentType) || + const needsPrism = ["python", "javascript", "css", "yaml"].includes(contentType) || content.includes("```") || content.includes("document.addEventListener('DOMContentLoaded', () => Prism.highlightAll());<\/script> ` : ""; + const jsonScript = contentType === "json" ? ` +