Files
WASasquatch-ComfyUI_Viewer/web/comfy_viewer.js
T

1416 lines
48 KiB
JavaScript

import { app } from "../../scripts/app.js";
const EXT_NAME = "WAS.ContentViewer";
const NODE_NAME = "WASComfyViewer";
const DEFAULT_NODE_SIZE = [600, 500];
const CONTROLS_HEIGHT = 32;
const INPUT_SLOT_HEIGHT = 26;
const STATE = {
container: null,
nodeIdToElements: new Map(),
cleanupIntervalId: null,
cleanupListenersAttached: false,
lastScale: 1,
};
function readCssVar(style, name) {
const v = style.getPropertyValue(name);
return v ? String(v).trim() : "";
}
function computeThemeTokens() {
const rootStyle = getComputedStyle(document.documentElement);
const bodyStyle = getComputedStyle(document.body);
const bg =
readCssVar(rootStyle, "--comfy-menu-bg") ||
readCssVar(rootStyle, "--background-color") ||
readCssVar(rootStyle, "--bg-color") ||
bodyStyle.backgroundColor ||
"#1a1a1a";
const fg =
readCssVar(rootStyle, "--input-text") ||
readCssVar(rootStyle, "--text-color") ||
readCssVar(rootStyle, "--fg-color") ||
bodyStyle.color ||
"#e0e0e0";
const border =
readCssVar(rootStyle, "--border-color") ||
readCssVar(rootStyle, "--comfy-border-color") ||
"#444";
const accent =
readCssVar(rootStyle, "--primary-color") ||
readCssVar(rootStyle, "--accent-color") ||
readCssVar(rootStyle, "--comfy-accent") ||
"#4a9eff";
return { bg, fg, border, accent };
}
function detectContentType(content) {
if (!content || typeof content !== "string") return "text";
const trimmed = content.trim();
if (trimmed.startsWith("<svg") ||
(trimmed.startsWith("<?xml") && trimmed.includes("<svg")) ||
(trimmed.startsWith("<") && trimmed.includes("xmlns") && trimmed.includes("<svg"))) {
return "svg";
}
if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") ||
(trimmed.startsWith("<") && (trimmed.includes("<head") || trimmed.includes("<body")))) {
return "html";
}
const scores = { html: 0, markdown: 0, python: 0, javascript: 0, css: 0 };
const htmlTags = ["<div", "<span", "<p>", "<h1", "<h2", "<h3", "<table", "<ul", "<ol",
"<img", "<a ", "<br", "<hr", "<em>", "<strong>", "<b>", "<i>", "<code>", "<pre>"];
for (const tag of htmlTags) {
if (trimmed.includes(tag)) scores.html += 2;
}
if (trimmed.startsWith("<") && trimmed.endsWith(">")) scores.html += 1;
const mdPatterns = [
[/^#{1,6}\s+.+/m, 3],
[/^\s*[-*+]\s+.+/m, 1],
[/^\s*\d+\.\s+.+/m, 1],
[/\[[^\]]+\]\([^)]+\)/, 2],
[/^\s*```/m, 3],
[/^\s*>/m, 1],
[/\*\*[^*]+\*\*/, 1],
[/~~[^~]+~~/, 1],
[/^\s*\|.+\|.+\|/m, 2],
[/!\[[^\]]*\]\([^)]+\)/, 2],
];
for (const [pattern, weight] of mdPatterns) {
if (pattern.test(trimmed)) scores.markdown += weight;
}
const pyPatterns = [
[/^import\s+\w+/m, 3],
[/^from\s+\w+\s+import/m, 3],
[/^def\s+\w+\s*\(/m, 3],
[/^class\s+\w+/m, 3],
[/self\.\w+/, 2],
[/__init__/, 2],
[/__name__/, 2],
[/:\s*$/m, 1],
[/^\s+return\s+/m, 1],
[/^\s+if\s+.+:/m, 1],
[/^\s+for\s+.+:/m, 1],
[/^\s+elif\s+/m, 2],
[/True|False|None/, 1],
];
for (const [pattern, weight] of pyPatterns) {
if (pattern.test(trimmed)) scores.python += weight;
}
const jsPatterns = [
[/^function\s+\w+\s*\(/m, 3],
[/^const\s+\w+\s*=/m, 3],
[/^let\s+\w+\s*=/m, 3],
[/^var\s+\w+\s*=/m, 2],
[/=>\s*\{/, 2],
[/^import\s+\{/m, 3],
[/^export\s+(default\s+)?/m, 3],
[/document\.\w+/, 2],
[/console\.\w+/, 2],
[/\.addEventListener\(/, 2],
[/===|!==/, 1],
[/\?\.\w+/, 2],
];
for (const [pattern, weight] of jsPatterns) {
if (pattern.test(trimmed)) scores.javascript += weight;
}
const cssPatterns = [
[/[.#][\w-]+\s*\{/, 2],
[/:\s*[\w-]+\s*;/, 2],
[/@media\s/, 3],
[/@import\s/, 2],
[/@keyframes\s/, 3],
[/^\s*[\w-]+:\s*[^;]+;/m, 1],
];
for (const [pattern, weight] of cssPatterns) {
if (pattern.test(trimmed)) scores.css += weight;
}
let maxType = "text";
let maxScore = 0;
for (const [type, score] of Object.entries(scores)) {
if (score > maxScore) {
maxScore = score;
maxType = type;
}
}
return maxScore >= 2 ? maxType : "text";
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function parseMarkdown(md) {
let html = md;
html = html.replace(/^```(\w*)\n([\s\S]*?)```$/gm, (_, lang, code) => {
return `<pre><code class="language-${lang || "text"}">${escapeHtml(code.trim())}</code></pre>`;
});
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
html = html.replace(/^######\s+(.*)$/gm, "<h6>$1</h6>");
html = html.replace(/^#####\s+(.*)$/gm, "<h5>$1</h5>");
html = html.replace(/^####\s+(.*)$/gm, "<h4>$1</h4>");
html = html.replace(/^###\s+(.*)$/gm, "<h3>$1</h3>");
html = html.replace(/^##\s+(.*)$/gm, "<h2>$1</h2>");
html = html.replace(/^#\s+(.*)$/gm, "<h1>$1</h1>");
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;">');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/__(.+?)__/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/(?<![a-zA-Z0-9\/_])_([^_\s][^_]*)_(?![a-zA-Z0-9\/_])/g, "<em>$1</em>");
html = html.replace(/~~(.+?)~~/g, "<del>$1</del>");
html = html.replace(/^>\s+(.*)$/gm, "<blockquote>$1</blockquote>");
html = html.replace(/^\d+\.\s+(.*)$/gm, "<oli>$1</oli>");
html = html.replace(/(<oli>.*<\/oli>\n?)+/g, (match) => {
return "<ol>" + match.replace(/<\/?oli>/g, (tag) => tag.replace("oli", "li")) + "</ol>";
});
html = html.replace(/^[-*+]\s+(.*)$/gm, "<uli>$1</uli>");
html = html.replace(/(<uli>.*<\/uli>\n?)+/g, (match) => {
return "<ul>" + match.replace(/<\/?uli>/g, (tag) => tag.replace("uli", "li")) + "</ul>";
});
html = html.replace(/^---$/gm, "<hr>");
html = html.replace(/^\*\*\*$/gm, "<hr>");
html = html.replace(/^(\|.+\|)\n(\|[-:\s|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, separatorRow, bodyRows) => {
const parseRow = (row, cellTag) => {
const cells = row.split("|").slice(1, -1);
return `<tr>${cells.map(c => `<${cellTag}>${c.trim()}</${cellTag}>`).join("")}</tr>`;
};
const alignments = separatorRow.split("|").slice(1, -1).map(cell => {
const trimmed = cell.trim();
if (trimmed.startsWith(":") && trimmed.endsWith(":")) return "center";
if (trimmed.endsWith(":")) return "right";
return "left";
});
const headerCells = headerRow.split("|").slice(1, -1);
const theadRow = `<tr>${headerCells.map((c, i) => `<th style="text-align:${alignments[i] || "left"}">${c.trim()}</th>`).join("")}</tr>`;
const bodyRowsArray = bodyRows.trim().split("\n");
const tbodyRows = bodyRowsArray.map(row => {
const cells = row.split("|").slice(1, -1);
return `<tr>${cells.map((c, i) => `<td style="text-align:${alignments[i] || "left"}">${c.trim()}</td>`).join("")}</tr>`;
}).join("");
return `<table><thead>${theadRow}</thead><tbody>${tbodyRows}</tbody></table>`;
});
html = html.replace(/\n\n/g, "</p><p>");
html = "<p>" + html + "</p>";
html = html.replace(/<p>\s*<(h[1-6]|ul|ol|pre|blockquote|hr|table)/g, "<$1");
html = html.replace(/<\/(h[1-6]|ul|ol|pre|blockquote|table)>\s*<\/p>/g, "</$1>");
html = html.replace(/<p>\s*<\/p>/g, "");
return html;
}
function renderCodeContent(content, language) {
const langMap = {
python: "python",
javascript: "javascript",
css: "css",
text: "plaintext",
};
const lang = langMap[language] || "plaintext";
return `<pre><code class="language-${lang}">${escapeHtml(content)}</code></pre>`;
}
async function createZipBlob(files) {
const crc32 = (data) => {
let crc = 0xFFFFFFFF;
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
table[i] = c;
}
for (let i = 0; i < data.length; i++) crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8);
return (crc ^ 0xFFFFFFFF) >>> 0;
};
const encoder = new TextEncoder();
const parts = [];
const centralDir = [];
let offset = 0;
for (const file of files) {
const nameBytes = encoder.encode(file.name);
const contentBytes = encoder.encode(file.content);
const crc = crc32(contentBytes);
const localHeader = new Uint8Array(30 + nameBytes.length);
const view = new DataView(localHeader.buffer);
view.setUint32(0, 0x04034B50, true);
view.setUint16(4, 20, true);
view.setUint16(6, 0, true);
view.setUint16(8, 0, true);
view.setUint16(10, 0, true);
view.setUint16(12, 0, true);
view.setUint32(14, crc, true);
view.setUint32(18, contentBytes.length, true);
view.setUint32(22, contentBytes.length, true);
view.setUint16(26, nameBytes.length, true);
view.setUint16(28, 0, true);
localHeader.set(nameBytes, 30);
const centralEntry = new Uint8Array(46 + nameBytes.length);
const cView = new DataView(centralEntry.buffer);
cView.setUint32(0, 0x02014B50, true);
cView.setUint16(4, 20, true);
cView.setUint16(6, 20, true);
cView.setUint16(8, 0, true);
cView.setUint16(10, 0, true);
cView.setUint16(12, 0, true);
cView.setUint16(14, 0, true);
cView.setUint32(16, crc, true);
cView.setUint32(20, contentBytes.length, true);
cView.setUint32(24, contentBytes.length, true);
cView.setUint16(28, nameBytes.length, true);
cView.setUint16(30, 0, true);
cView.setUint16(32, 0, true);
cView.setUint16(34, 0, true);
cView.setUint16(36, 0, true);
cView.setUint32(38, 0, true);
cView.setUint32(42, offset, true);
centralEntry.set(nameBytes, 46);
parts.push(localHeader, contentBytes);
centralDir.push(centralEntry);
offset += localHeader.length + contentBytes.length;
}
const centralDirOffset = offset;
let centralDirSize = 0;
for (const entry of centralDir) {
parts.push(entry);
centralDirSize += entry.length;
}
const endRecord = new Uint8Array(22);
const endView = new DataView(endRecord.buffer);
endView.setUint32(0, 0x06054B50, true);
endView.setUint16(4, 0, true);
endView.setUint16(6, 0, true);
endView.setUint16(8, files.length, true);
endView.setUint16(10, files.length, true);
endView.setUint32(12, centralDirSize, true);
endView.setUint32(16, centralDirOffset, true);
endView.setUint16(20, 0, true);
parts.push(endRecord);
return new Blob(parts, { type: "application/zip" });
}
function buildIframeContent(content, contentType, theme) {
const baseStyles = `
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 4px;
background: ${theme.bg};
color: ${theme.fg};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
overflow-x: hidden;
word-wrap: break-word;
}
a { color: ${theme.accent}; }
pre {
background: rgba(0,0,0,0.3);
padding: 12px;
border-radius: 6px;
overflow-x: hidden;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
font-family: "Fira Code", "Consolas", "Monaco", monospace;
font-size: 13px;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: "Fira Code", "Consolas", "Monaco", monospace;
}
pre code {
background: transparent;
padding: 0;
}
blockquote {
border-left: 4px solid ${theme.accent};
margin: 12px 0;
padding: 8px 16px;
background: rgba(0,0,0,0.15);
}
img { max-width: 100%; height: auto; border-radius: 4px; }
table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
border: 1px solid ${theme.border};
}
th, td {
border: 1px solid ${theme.border};
padding: 8px;
text-align: left;
}
th { background: rgba(0,0,0,0.2); }
tr:last-child td { border-bottom: 1px solid ${theme.border}; }
hr {
border: none;
border-top: 1px solid ${theme.border};
margin: 16px 0;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 20px;
margin-bottom: 10px;
color: ${theme.fg};
}
ul, ol { padding-left: 24px; }
li { margin: 4px 0; }
.list-item {
background: rgba(0,0,0,0.2);
border: 1px solid ${theme.border};
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
position: relative;
}
.list-item:last-child { margin-bottom: 0; }
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.list-index {
display: inline-block;
background: ${theme.accent};
color: #fff;
font-size: 11px;
font-weight: bold;
padding: 2px 6px;
border-radius: 4px;
}
.copy-btn {
background: transparent;
border: 1px solid ${theme.border};
border-radius: 4px;
color: ${theme.fg};
cursor: pointer;
padding: 4px 8px;
font-size: 11px;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.copy-btn:hover {
background: ${theme.accent};
color: #fff;
border-color: ${theme.accent};
}
.copy-btn.copied {
background: #22c55e;
border-color: #22c55e;
color: #fff;
}
.list-content {
white-space: pre-wrap;
word-wrap: break-word;
}
`;
const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n";
const isListContent = content.includes(LIST_SEPARATOR);
let bodyContent = "";
if (isListContent) {
const items = content.split(LIST_SEPARATOR);
const itemsData = JSON.stringify(items);
bodyContent = items.map((item, idx) => {
const itemType = detectContentType(item);
let itemHtml = "";
if (itemType === "html") {
itemHtml = item;
} else if (itemType === "markdown") {
itemHtml = parseMarkdown(item);
} else {
itemHtml = `<div class="list-content">${escapeHtml(item)}</div>`;
}
return `<div class="list-item">
<div class="list-header">
<span class="list-index">${idx + 1} / ${items.length}</span>
<button class="copy-btn" data-index="${idx}" onclick="copyItem(${idx})">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Copy
</button>
</div>
${itemHtml}
</div>`;
}).join("");
bodyContent += `<script>
const items = ${itemsData};
function copyItem(idx) {
const text = items[idx];
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('[data-index="' + idx + '"]');
btn.classList.add('copied');
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg> Copy';
}, 1500);
});
}
<\/script>`;
} else switch (contentType) {
case "html":
if (content.includes("<html") || content.includes("<!DOCTYPE")) {
const hasCustomStyles = content.includes("<style") ||
content.includes("background") ||
content.includes("color:");
if (hasCustomStyles) {
return content;
}
return content.replace("</head>", `<style>${baseStyles}</style></head>`);
}
bodyContent = content;
break;
case "markdown":
bodyContent = parseMarkdown(content);
break;
case "svg":
bodyContent = `<div style="display:flex;justify-content:center;align-items:center;min-height:100%;padding:16px;">${content}</div>`;
break;
case "python":
case "javascript":
case "css":
case "text":
bodyContent = renderCodeContent(content, contentType);
break;
default:
bodyContent = `<pre>${escapeHtml(content)}</pre>`;
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${baseStyles}</style>
</head>
<body>${bodyContent}</body>
</html>`;
}
function getActiveGraphNodes() {
const g = app?.graph || app?.canvas?.graph;
const nodes = g?._nodes || g?.nodes;
return Array.isArray(nodes) ? nodes : [];
}
function isViewerNode(node) {
try {
return (
node?.comfyClass === NODE_NAME ||
node?.type === NODE_NAME ||
node?.constructor?.comfyClass === NODE_NAME ||
node?.constructor?.type === NODE_NAME
);
} catch (e) {
console.error("[WAS Viewer] isViewerNode error:", e);
return false;
}
}
function removeElementsByKey(key) {
try {
const elements = STATE.nodeIdToElements.get(key);
if (!elements) return;
elements.wrapper?.remove();
} catch (e) {
console.error("[WAS Viewer] removeElementsByKey error:", e);
} finally {
STATE.nodeIdToElements.delete(key);
}
}
function cleanupOrphanElements() {
try {
const nodes = getActiveGraphNodes();
const activeIds = new Set(
nodes
.filter((n) => isViewerNode(n))
.map((n) => String(n.id))
);
for (const key of Array.from(STATE.nodeIdToElements.keys())) {
if (!activeIds.has(key)) {
removeElementsByKey(key);
}
}
if (STATE.nodeIdToElements.size === 0 && STATE.container) {
try {
STATE.container.remove();
} catch (e) {
console.error("[WAS Viewer] container remove error:", e);
}
STATE.container = null;
}
} catch (e) {
console.error("[WAS Viewer] cleanupOrphanElements error:", e);
}
}
function ensureCleanupRunning() {
if (STATE.cleanupIntervalId != null) return;
STATE.cleanupIntervalId = window.setInterval(() => cleanupOrphanElements(), 1000);
if (!STATE.cleanupListenersAttached) {
STATE.cleanupListenersAttached = true;
const onVis = () => cleanupOrphanElements();
const onFocus = () => cleanupOrphanElements();
window.addEventListener("visibilitychange", onVis);
window.addEventListener("focus", onFocus);
}
}
function getContainer() {
if (STATE.container) return STATE.container;
const el = document.createElement("div");
el.id = "was-viewer-overlay";
el.style.position = "absolute";
el.style.left = "0";
el.style.top = "0";
el.style.width = "0";
el.style.height = "0";
el.style.pointerEvents = "none";
el.style.zIndex = "100";
document.body.appendChild(el);
STATE.container = el;
return el;
}
function getWidgetValue(node, name) {
const widget = node.widgets?.find((w) => w.name === name);
if (widget?.value) return widget.value;
const widgetIndex = node.widgets?.findIndex((w) => w.name === name);
if (widgetIndex >= 0 && node.widgets_values?.[widgetIndex]) {
return node.widgets_values[widgetIndex];
}
return "";
}
function setWidgetValue(node, name, value) {
const widget = node.widgets?.find((w) => w.name === name);
if (widget) {
widget.value = value;
}
const widgetIndex = node.widgets?.findIndex((w) => w.name === name);
if (widgetIndex >= 0) {
if (!node.widgets_values) node.widgets_values = [];
node.widgets_values[widgetIndex] = value;
}
}
function getConnectedContent(node) {
const inputLink = node.inputs?.find((i) => i.name === "content")?.link;
if (inputLink == null) return null;
const link = app.graph.links?.[inputLink];
if (!link) return null;
const originNode = app.graph.getNodeById(link.origin_id);
if (!originNode) return null;
if (originNode.getOutputData) {
const outputData = originNode.getOutputData(link.origin_slot);
if (outputData && typeof outputData === "string") {
return outputData;
}
}
const widgets = originNode.widgets || [];
for (let i = 0; i < widgets.length; i++) {
const w = widgets[i];
if (w && typeof w.value === "string" && w.value.length > 0) {
return w.value;
}
}
return null;
}
function getNodeContent(node, elements) {
const manualContent = getWidgetValue(node, "manual_content");
if (manualContent && (elements?.hasBackendContent || elements?.hasUserEdits)) {
return manualContent;
}
const connectedContent = getConnectedContent(node);
if (connectedContent) {
return connectedContent;
}
return manualContent || "";
}
function createControlsBar(node, elements) {
const theme = computeThemeTokens();
const controls = document.createElement("div");
controls.style.cssText = `
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: ${theme.bg};
border-bottom: 1px solid ${theme.border};
border-radius: 8px 8px 0 0;
pointer-events: auto;
box-sizing: border-box;
flex-shrink: 0;
min-height: ${CONTROLS_HEIGHT}px;
`;
const typeLabel = document.createElement("span");
typeLabel.style.cssText = `
font-size: 11px;
color: ${theme.fg};
opacity: 0.7;
font-family: sans-serif;
margin-right: auto;
`;
typeLabel.textContent = "Type: detecting...";
controls.appendChild(typeLabel);
const editBtn = document.createElement("button");
editBtn.textContent = "Edit";
editBtn.style.cssText = `
padding: 4px 12px;
font-size: 11px;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
color: ${theme.fg};
cursor: pointer;
font-family: sans-serif;
`;
editBtn.onmouseenter = () => { editBtn.style.background = theme.accent; editBtn.style.color = "#fff"; };
editBtn.onmouseleave = () => { editBtn.style.background = theme.bg; editBtn.style.color = theme.fg; };
editBtn.onclick = () => {
if (elements.isEditing) {
let newContent = "";
if (elements.listTextareas && elements.listTextareas.length > 0) {
const values = elements.listTextareas.map(ta => ta.value);
newContent = values.join("\n---LIST_SEPARATOR---\n");
elements.listEditContainer.style.display = "none";
elements.listTextareas = [];
} else {
newContent = elements.textarea.value;
elements.textarea.style.display = "none";
}
setWidgetValue(node, "manual_content", newContent);
elements.hasBackendContent = false;
elements.hasUserEdits = true;
elements.lastContentHash = "";
elements.isEditing = false;
elements.iframe.style.display = "block";
editBtn.textContent = "Edit";
node.setDirtyCanvas?.(true, true);
app.graph?.change?.();
updateIframeContent(node, elements);
} else {
elements.isEditing = true;
const currentContent = getNodeContent(node, elements);
const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n";
if (currentContent.includes(LIST_SEPARATOR)) {
const items = currentContent.split(LIST_SEPARATOR);
if (!elements.listEditContainer && elements.contentWrapper) {
elements.listEditContainer = document.createElement("div");
elements.listEditContainer.style.cssText = `
position: absolute;
inset: 0;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
background: ${theme.bg};
`;
elements.contentWrapper.appendChild(elements.listEditContainer);
}
if (!elements.listEditContainer) return;
elements.listEditContainer.innerHTML = "";
elements.listEditContainer.style.display = "flex";
elements.listTextareas = [];
items.forEach((item, idx) => {
const itemContainer = document.createElement("div");
itemContainer.style.cssText = `
background: rgba(0,0,0,0.2);
border: 1px solid ${theme.border};
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
`;
const label = document.createElement("span");
label.textContent = `${idx + 1} / ${items.length}`;
label.style.cssText = `
background: ${theme.accent};
color: #fff;
font-size: 11px;
font-weight: bold;
padding: 2px 6px;
border-radius: 4px;
align-self: flex-start;
`;
const ta = document.createElement("textarea");
ta.value = item;
ta.style.cssText = `
width: 100%;
min-height: 80px;
resize: vertical;
background: rgba(0,0,0,0.3);
color: ${theme.fg};
border: 1px solid ${theme.border};
border-radius: 4px;
padding: 8px;
font-family: monospace;
font-size: 13px;
`;
itemContainer.appendChild(label);
itemContainer.appendChild(ta);
elements.listEditContainer.appendChild(itemContainer);
elements.listTextareas.push(ta);
});
elements.iframe.style.display = "none";
elements.textarea.style.display = "none";
} else {
elements.textarea.value = currentContent;
elements.textarea.style.display = "block";
elements.iframe.style.display = "none";
if (elements.listEditContainer) elements.listEditContainer.style.display = "none";
}
editBtn.textContent = "Save";
if (elements.listTextareas?.[0]) {
elements.listTextareas[0].focus();
} else {
elements.textarea.focus();
}
}
};
controls.appendChild(editBtn);
const clearBtn = document.createElement("button");
clearBtn.textContent = "Clear";
clearBtn.style.cssText = `
padding: 4px 12px;
font-size: 11px;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
color: ${theme.fg};
cursor: pointer;
font-family: sans-serif;
`;
clearBtn.onmouseenter = () => { clearBtn.style.background = "#c44"; clearBtn.style.color = "#fff"; };
clearBtn.onmouseleave = () => { clearBtn.style.background = theme.bg; clearBtn.style.color = theme.fg; };
clearBtn.onclick = () => {
setWidgetValue(node, "manual_content", "");
const contentWidget = node.widgets?.find((w) => w.name === "content");
if (contentWidget) contentWidget.value = "";
elements.lastContentHash = "";
elements.textarea.value = "";
if (elements.isEditing) {
elements.isEditing = false;
elements.textarea.style.display = "none";
elements.iframe.style.display = "block";
editBtn.textContent = "Edit";
}
node.setDirtyCanvas?.(true, true);
app.graph?.change?.();
const theme = computeThemeTokens();
const emptyHtml = buildIframeContent("<p style='opacity:0.5;text-align:center;margin-top:40px;'>No content. Click Edit to add content or connect a STRING input.</p>", "html", theme);
elements.iframe.srcdoc = emptyHtml;
if (elements.typeLabel) elements.typeLabel.textContent = "Type: Text";
};
controls.appendChild(clearBtn);
const fullscreenBtn = document.createElement("button");
fullscreenBtn.textContent = "⛶";
fullscreenBtn.title = "Fullscreen";
fullscreenBtn.style.cssText = `
padding: 0 4px;
font-size: 14px;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
color: ${theme.fg};
cursor: pointer;
font-family: sans-serif;
`;
fullscreenBtn.onmouseenter = () => { fullscreenBtn.style.background = theme.accent; fullscreenBtn.style.color = "#fff"; };
fullscreenBtn.onmouseleave = () => { fullscreenBtn.style.background = theme.bg; fullscreenBtn.style.color = theme.fg; };
fullscreenBtn.onclick = () => {
if (elements.iframe) {
if (elements.iframe.requestFullscreen) {
elements.iframe.requestFullscreen();
} else if (elements.iframe.webkitRequestFullscreen) {
elements.iframe.webkitRequestFullscreen();
} else if (elements.iframe.mozRequestFullScreen) {
elements.iframe.mozRequestFullScreen();
} else if (elements.iframe.msRequestFullscreen) {
elements.iframe.msRequestFullscreen();
}
}
};
controls.appendChild(fullscreenBtn);
const downloadBtn = document.createElement("button");
downloadBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" width="16" height="16">
<path fill="${theme.fg}" d="M5.625 15c0-.4142-.33579-.75-.75-.75s-.75.3358-.75.75h1.5Zm-.75 1h-.75.75Zm14.4-1c0-.4142-.3358-.75-.75-.75s-.75.3358-.75.75h1.5Zm-8.1664.5387c-.2547.3266-.1965.7979.1301 1.0527.3266.2547.7979.1965 1.0527-.1301l-1.1828-.9226Zm5.0828-4.0774c.2547-.3266.1965-.7979-.1301-1.0527-.3266-.2547-.7979-.1965-1.0527.1301l1.1828.9226Zm-5.0828 5c.2548.3266.7261.3848 1.0527.1301.3266-.2548.3848-.7261.1301-1.0527l-1.1828.9226Zm-2.71722-5.9226c-.25476-.3266-.72605-.3848-1.05265-.1301-.32661.2548-.38486.7261-.13011 1.0527l1.18276-.9226ZM10.95 16c0 .4142.3358.75.75.75s.75-.3358.75-.75h-1.5Zm1.5-11c0-.41421-.3358-.75-.75-.75s-.75.33579-.75.75h1.5ZM4.125 15v1h1.5v-1h-1.5Zm0 1c0 2.0531 1.62757 3.75 3.675 3.75v-1.5c-1.18343 0-2.175-.9893-2.175-2.25h-1.5ZM7.8 19.75h7.8v-1.5H7.8v1.5Zm7.8 0c2.0474 0 3.675-1.6969 3.675-3.75h-1.5c0 1.2607-.9916 2.25-2.175 2.25v1.5ZM19.275 16v-1h-1.5v1h1.5Zm-6.9836.4613 3.9-5-1.1828-.9226-3.9 5 1.1828.9226Zm0-.9226-3.90002-5-1.18276.9226 3.89998 5 1.1828-.9226ZM12.45 16V5h-1.5v11h1.5Z"/>
</svg>`;
downloadBtn.title = "Download";
downloadBtn.style.cssText = `
padding: 2px 4px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid ${theme.border};
border-radius: 4px;
background: ${theme.bg};
cursor: pointer;
`;
const updateDownloadSvg = (color) => {
const path = downloadBtn.querySelector("path");
if (path) path.setAttribute("fill", color);
};
downloadBtn.onmouseenter = () => { downloadBtn.style.background = theme.accent; updateDownloadSvg("#fff"); };
downloadBtn.onmouseleave = () => { downloadBtn.style.background = theme.bg; updateDownloadSvg(theme.fg); };
downloadBtn.onclick = async () => {
let content = getNodeContent(node, elements);
if (!content) {
content = elements.textarea?.value || "";
}
if (!content) {
alert("No content to download");
return;
}
const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
try {
if (content.includes(LIST_SEPARATOR)) {
const items = content.split(LIST_SEPARATOR);
const files = items.map((item, idx) => {
const itemType = detectContentType(item);
const extensions = { html: "html", svg: "svg", markdown: "md", python: "py", javascript: "js", css: "css", text: "txt" };
const ext = extensions[itemType] || "txt";
return { name: `item_${String(idx + 1).padStart(3, "0")}.${ext}`, content: item };
});
const zipBlob = await createZipBlob(files);
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = `content_${timestamp}.zip`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
} else {
const contentType = detectContentType(content);
const extensions = { html: "html", svg: "svg", markdown: "md", python: "py", javascript: "js", css: "css", text: "txt" };
const ext = extensions[contentType] || "txt";
const filename = `content_${timestamp}.${ext}`;
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
}
} catch (e) {
console.error("[WAS Viewer] download error:", e);
}
};
controls.appendChild(downloadBtn);
elements.typeLabel = typeLabel;
return controls;
}
function ensureElementsForNode(node) {
cleanupOrphanElements();
ensureCleanupRunning();
const key = String(node.id);
const existing = STATE.nodeIdToElements.get(key);
if (existing) return existing;
const theme = computeThemeTokens();
const wrapper = document.createElement("div");
wrapper.style.cssText = `
position: absolute;
display: flex;
flex-direction: column;
pointer-events: none;
`;
const contentWrapper = document.createElement("div");
const elements = {
wrapper,
contentWrapper,
controls: null,
iframe: null,
textarea: null,
typeLabel: null,
lastContentHash: "",
isEditing: false,
hasBackendContent: false,
hasUserEdits: false,
listEditContainer: null,
listTextareas: null,
};
const controls = createControlsBar(node, elements);
wrapper.appendChild(controls);
elements.controls = controls;
contentWrapper.style.cssText = `
position: relative;
flex: 1;
overflow: hidden;
border-radius: 0 0 8px 8px;
pointer-events: auto;
`;
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "allow-scripts allow-forms allow-popups allow-modals allow-pointer-lock allow-downloads");
iframe.setAttribute("allow", "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen");
iframe.setAttribute("allowfullscreen", "true");
iframe.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
pointer-events: auto;
background: ${theme.bg};
border-radius: 0 0 8px 8px;
`;
contentWrapper.appendChild(iframe);
elements.iframe = iframe;
const initialHtml = buildIframeContent("<p style='opacity:0.5;text-align:center;margin-top:40px;'>No content. Click Edit to add content or connect a STRING input.</p>", "html", theme);
iframe.srcdoc = initialHtml;
if (elements.typeLabel) elements.typeLabel.textContent = "Type: Text";
const textarea = document.createElement("textarea");
textarea.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
pointer-events: auto;
background: ${theme.bg};
color: ${theme.fg};
font-family: "Fira Code", "Consolas", "Monaco", monospace;
font-size: 13px;
padding: 16px;
resize: none;
display: none;
box-sizing: border-box;
border-radius: 0 0 8px 8px;
`;
textarea.placeholder = "Enter HTML, Markdown, or plain text content here...";
contentWrapper.appendChild(textarea);
elements.textarea = textarea;
wrapper.appendChild(contentWrapper);
getContainer().appendChild(wrapper);
STATE.nodeIdToElements.set(key, elements);
const oldOnRemoved = node.onRemoved;
node.onRemoved = function () {
try {
removeElementsByKey(String(this.id));
cleanupOrphanElements();
} catch (e) {
console.error("[WAS Viewer] onRemoved error:", e);
}
return oldOnRemoved ? oldOnRemoved.apply(this, arguments) : undefined;
};
return elements;
}
function updateElementsRect(node, elements) {
const canvas = app.canvas;
const canvasEl = canvas?.canvas;
if (!canvasEl) return;
const rect = canvasEl.getBoundingClientRect();
const ds = canvas.ds;
const scale = ds?.scale ?? 1;
const offset = ds?.offset ?? [0, 0];
const x = rect.left + (node.pos[0] + offset[0]) * scale;
const y = rect.top + (node.pos[1] + offset[1]) * scale;
const w = node.size[0] * scale;
const h = node.size[1] * scale;
const titleHRaw =
(typeof node?.title_height === "number" && Number.isFinite(node.title_height) && node.title_height) ||
(typeof node?.constructor?.title_height === "number" &&
Number.isFinite(node.constructor.title_height) &&
node.constructor.title_height) ||
globalThis?.LiteGraph?.NODE_TITLE_HEIGHT ||
30;
const insetX = 8;
const insetBottom = 8;
const innerX = x + (insetX * scale);
const innerY = y + ((titleHRaw - 5) * scale);
const innerW = (node.size[0] - insetX * 2);
const innerH = (node.size[1] - titleHRaw + 5 - insetBottom);
const nx = Number.isFinite(innerX) ? innerX : 0;
const ny = Number.isFinite(innerY) ? innerY : 0;
const nw = Number.isFinite(innerW) ? innerW : 0;
const nh = Number.isFinite(innerH) ? innerH : 0;
elements.wrapper.style.left = `${Math.round(nx)}px`;
elements.wrapper.style.top = `${Math.round(ny)}px`;
elements.wrapper.style.width = `${Math.max(0, Math.round(nw))}px`;
elements.wrapper.style.height = `${Math.max(0, Math.round(nh))}px`;
elements.wrapper.style.transform = `scale(${scale})`;
elements.wrapper.style.transformOrigin = 'top left';
const isCollapsed = !!node.flags?.collapsed;
const hasArea = nw >= 2 && nh >= 2;
elements.wrapper.style.display = !isCollapsed && hasArea ? "flex" : "none";
}
function updateIframeContent(node, elements) {
if (elements.isEditing) return;
const content = getNodeContent(node, elements);
const contentHash = content ? content.length + "_" + content.slice(0, 100) : "";
console.log("[WAS Viewer] updateIframeContent:", {
contentLength: content?.length,
contentHash,
lastHash: elements.lastContentHash,
hasBackendContent: elements.hasBackendContent,
hasUserEdits: elements.hasUserEdits,
contentPreview: content?.slice(0, 50)
});
if (contentHash === elements.lastContentHash) return;
elements.lastContentHash = contentHash;
const contentType = detectContentType(content);
const theme = computeThemeTokens();
if (elements.typeLabel) {
const typeNames = {
html: "HTML",
svg: "SVG",
markdown: "Markdown",
python: "Python",
javascript: "JavaScript",
css: "CSS",
text: "Text",
};
elements.typeLabel.textContent = `Type: ${typeNames[contentType] || "Unknown"}`;
}
if (!content) {
const emptyHtml = buildIframeContent("<p style='opacity:0.5;text-align:center;margin-top:40px;'>No content. Click Edit to add content or connect a STRING input.</p>", "html", theme);
elements.iframe.srcdoc = emptyHtml;
return;
}
const html = buildIframeContent(content, contentType, theme);
const needsBlobUrl = contentType === "html" && (
content.includes("WebAssembly") ||
content.includes("wasm") ||
content.includes("createUnityInstance") ||
content.includes("ServiceWorker") ||
content.includes("SharedArrayBuffer")
);
if (needsBlobUrl) {
if (elements.lastBlobUrl) {
URL.revokeObjectURL(elements.lastBlobUrl);
}
const blob = new Blob([html], { type: "text/html" });
elements.lastBlobUrl = URL.createObjectURL(blob);
elements.iframe.src = elements.lastBlobUrl;
} else {
if (elements.lastBlobUrl) {
URL.revokeObjectURL(elements.lastBlobUrl);
elements.lastBlobUrl = null;
}
elements.iframe.src = "";
elements.iframe.srcdoc = html;
}
}
app.registerExtension({
name: EXT_NAME,
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_NAME) return;
ensureCleanupRunning();
const oldOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = oldOnNodeCreated ? oldOnNodeCreated.apply(this, arguments) : undefined;
try {
if (!Array.isArray(this.size) || this.size.length < 2) {
this.size = [...DEFAULT_NODE_SIZE];
} else {
this.size[0] = Math.max(this.size[0] ?? 0, DEFAULT_NODE_SIZE[0]);
this.size[1] = Math.max(this.size[1] ?? 0, DEFAULT_NODE_SIZE[1]);
}
let manualWidget = this.widgets?.find((w) => w.name === "manual_content");
if (!manualWidget) {
manualWidget = this.addWidget("text", "manual_content", "", () => {});
}
if (manualWidget) {
manualWidget.type = "hidden";
manualWidget.computeSize = () => [0, -4];
manualWidget.serializeValue = () => manualWidget.value;
}
for (const w of this.widgets || []) {
if (w.name === "manual_content") continue;
w.type = "hidden";
w.computeSize = () => [0, -4];
}
this.setDirtyCanvas?.(true, true);
const node = this;
setTimeout(() => {
try {
const elements = ensureElementsForNode(node);
updateElementsRect(node, elements);
updateIframeContent(node, elements);
} catch (e) {
console.error("[WAS Viewer] onNodeCreated delayed init error:", e);
}
}, 50);
} catch (e) {
console.error("[WAS Viewer] onNodeCreated error:", e);
}
return r;
};
const oldOnDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
const r = oldOnDrawForeground ? oldOnDrawForeground.apply(this, arguments) : undefined;
try {
const elements = ensureElementsForNode(this);
updateElementsRect(this, elements);
updateIframeContent(this, elements);
} catch (e) {
console.error("[WAS Viewer] onDrawForeground error:", e);
}
return r;
};
const oldOnResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
const r = oldOnResize ? oldOnResize.apply(this, arguments) : undefined;
try {
const elements = ensureElementsForNode(this);
updateElementsRect(this, elements);
} catch (e) {
console.error("[WAS Viewer] onResize error:", e);
}
return r;
};
const oldOnConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (slotType, slotIndex, isConnected, link_info) {
const r = oldOnConnectionsChange ? oldOnConnectionsChange.apply(this, arguments) : undefined;
try {
const isInputSlot = slotType === 1;
const contentInputIndex = this.inputs?.findIndex((i) => i.name === "content");
const isContentInput = isInputSlot && contentInputIndex === slotIndex;
if (isContentInput) {
const contentWidget = this.widgets?.find((w) => w.name === "content");
if (contentWidget) {
contentWidget.value = "";
}
if (!isConnected) {
setWidgetValue(this, "manual_content", "");
}
const elements = STATE.nodeIdToElements.get(String(this.id));
if (elements) {
elements.lastContentHash = "";
elements.hasBackendContent = false;
elements.hasUserEdits = false;
}
const node = this;
const updateContent = () => {
try {
const el = STATE.nodeIdToElements.get(String(node.id));
if (el) {
el.lastContentHash = "";
updateIframeContent(node, el);
}
} catch (e) {
console.error("[WAS Viewer] onConnectionsChange delayed update error:", e);
}
};
setTimeout(updateContent, 50);
}
} catch (e) {
console.error("[WAS Viewer] onConnectionsChange error:", e);
}
return r;
};
const oldOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (data) {
const r = oldOnConfigure ? oldOnConfigure.apply(this, arguments) : undefined;
try {
const manualWidget = this.widgets?.find((w) => w.name === "manual_content");
if (manualWidget) {
manualWidget.type = "hidden";
manualWidget.computeSize = () => [0, -4];
if (data?.widgets_values) {
const idx = this.widgets.findIndex(w => w.name === "manual_content");
if (idx >= 0 && data.widgets_values[idx] !== undefined) {
manualWidget.value = data.widgets_values[idx];
}
}
}
const elements = STATE.nodeIdToElements.get(String(this.id));
if (elements) {
elements.lastContentHash = "";
}
} catch (e) {
console.error("[WAS Viewer] onConfigure error:", e);
}
return r;
};
const oldOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
const r = oldOnExecuted ? oldOnExecuted.apply(this, arguments) : undefined;
try {
console.log("[WAS Viewer] onExecuted message:", JSON.stringify(message));
const newContent = message?.text?.[0];
console.log("[WAS Viewer] newContent length:", newContent?.length, "full:", newContent);
if (newContent !== undefined && newContent !== null && newContent !== "") {
setWidgetValue(this, "manual_content", String(newContent));
const elements = STATE.nodeIdToElements.get(String(this.id));
if (elements) {
elements.hasBackendContent = true;
elements.lastContentHash = "";
updateIframeContent(this, elements);
}
} else {
console.log("[WAS Viewer] Skipping empty content");
}
} catch (e) {
console.error("[WAS Viewer] onExecuted error:", e);
}
return r;
};
const oldSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function () {
const data = oldSerialize ? oldSerialize.apply(this, arguments) : {};
try {
const manualWidget = this.widgets?.find((w) => w.name === "manual_content");
if (manualWidget && manualWidget.value) {
if (!data.widgets_values) {
data.widgets_values = [];
}
const idx = this.widgets.findIndex(w => w.name === "manual_content");
if (idx >= 0) {
while (data.widgets_values.length <= idx) {
data.widgets_values.push(null);
}
data.widgets_values[idx] = manualWidget.value;
}
}
} catch (e) {
console.error("[WAS Viewer] serialize error:", e);
}
return data;
};
},
});