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 += `${listTag}>`;
+ 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 += ` ${listTag}>`;
+ 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, '
');
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" ? `
+