Files

1510 lines
49 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import {
loadAllViews,
initializeViewScripts,
detectContentType,
getViewDisplayName,
onViewsRefresh,
handleViewMessage,
getViewState,
injectViewState,
getViewScriptData,
isMultiviewContent,
parseMultiviewContent,
getMultiviewContent,
isViewUI,
getViewSandboxAttributes,
viewNeedsBlobUrl,
getViewDirectUrl,
getViewContentMessage
} from "./views/view_loader.js";
import { loadPrismScripts } from "./views/code_scripts.js";
import { computeThemeTokens, getFullTheme, themeToCssVars } from "./utils/theme.js";
import { createControlsBar, CONTROLS_HEIGHT, updateViewSelector, updateControlsForUI } from "./controls/controls_bar.js";
import { buildIframeContent, LIST_SEPARATOR } from "./iframe/iframe_builder.js";
const EXT_NAME = "WAS.ContentViewer";
const NODE_NAME = "WASComfyViewer";
// Diagnostic v2: fresh key to avoid stale data confusion
const _diag2 = [];
function diag(msg) {
const ts = performance.now().toFixed(0);
_diag2.push(`[${ts}] ${msg}`);
try { localStorage.setItem('__was_diag2', JSON.stringify(_diag2.slice(-200))); } catch(e) {}
}
const DEFAULT_NODE_SIZE = [600, 500];
const STATE = {
container: null,
nodeIdToElements: new Map(),
cleanupIntervalId: null,
cleanupListenersAttached: false,
lastScale: 1,
viewsInitialized: false,
updateContainerBounds: null,
iframeLoadQueue: [],
iframeLoading: false,
hangerCheckIntervalId: null,
themeSyncAttached: false,
lastThemeCssVars: "",
themeUpdatePending: false,
};
function getThemeCssVars() {
try {
return themeToCssVars(getFullTheme());
} catch (e) {
return "";
}
}
function applyThemeToElements(theme, elements) {
if (!theme || !elements) return;
const bindHover = (btn, onEnter, onLeave) => {
if (!btn) return;
btn.onmouseenter = onEnter;
btn.onmouseleave = onLeave;
};
if (elements.controls) {
elements.controls.style.background = theme.bg;
elements.controls.style.borderBottomColor = theme.border;
}
if (elements.typeLabel) {
elements.typeLabel.style.color = theme.fg;
}
if (elements.viewSelector) {
elements.viewSelector.style.background = theme.bg;
elements.viewSelector.style.color = theme.fg;
elements.viewSelector.style.borderColor = theme.border;
}
if (elements.toggleAllBtn) {
elements.toggleAllBtn.style.background = theme.bg;
elements.toggleAllBtn.style.color = theme.fg;
elements.toggleAllBtn.style.borderColor = theme.border;
bindHover(
elements.toggleAllBtn,
() => {
elements.toggleAllBtn.style.background = theme.accent;
elements.toggleAllBtn.style.color = "#fff";
},
() => {
elements.toggleAllBtn.style.background = theme.bg;
elements.toggleAllBtn.style.color = theme.fg;
}
);
}
if (elements.editBtn) {
elements.editBtn.style.background = theme.bg;
elements.editBtn.style.color = theme.fg;
elements.editBtn.style.borderColor = theme.border;
bindHover(
elements.editBtn,
() => {
elements.editBtn.style.background = theme.accent;
elements.editBtn.style.color = "#fff";
},
() => {
elements.editBtn.style.background = theme.bg;
elements.editBtn.style.color = theme.fg;
}
);
}
if (elements.clearBtn) {
elements.clearBtn.style.background = theme.bg;
elements.clearBtn.style.color = theme.fg;
elements.clearBtn.style.borderColor = theme.border;
bindHover(
elements.clearBtn,
() => {
elements.clearBtn.style.background = "#c44";
elements.clearBtn.style.color = "#fff";
},
() => {
elements.clearBtn.style.background = theme.bg;
elements.clearBtn.style.color = theme.fg;
}
);
}
if (elements.fullscreenBtn) {
elements.fullscreenBtn.style.background = theme.bg;
elements.fullscreenBtn.style.color = theme.fg;
elements.fullscreenBtn.style.borderColor = theme.border;
bindHover(
elements.fullscreenBtn,
() => {
elements.fullscreenBtn.style.background = theme.accent;
elements.fullscreenBtn.style.color = "#fff";
},
() => {
elements.fullscreenBtn.style.background = theme.bg;
elements.fullscreenBtn.style.color = theme.fg;
}
);
}
if (elements.downloadBtn) {
elements.downloadBtn.style.background = theme.bg;
elements.downloadBtn.style.borderColor = theme.border;
const path = elements.downloadBtn.querySelector?.("path");
if (path) path.setAttribute("fill", theme.fg);
const updateDownloadSvg = (color) => {
const p = elements.downloadBtn.querySelector?.("path");
if (p) p.setAttribute("fill", color);
};
bindHover(
elements.downloadBtn,
() => {
elements.downloadBtn.style.background = theme.accent;
updateDownloadSvg("#fff");
},
() => {
elements.downloadBtn.style.background = theme.bg;
updateDownloadSvg(theme.fg);
}
);
}
if (elements.wrapper) {
elements.wrapper.style.background = theme.bg;
}
if (elements.iframe) {
elements.iframe.style.background = theme.bg;
}
if (elements.textarea) {
elements.textarea.style.background = theme.bg;
elements.textarea.style.color = theme.fg;
}
if (elements.mutedOverlay) {
elements.mutedOverlay.style.background = theme.bg;
}
if (elements.lowQualityOverlay) {
elements.lowQualityOverlay.style.background = theme.bg;
}
}
function broadcastThemeUpdate() {
const cssVars = getThemeCssVars();
if (!cssVars || cssVars === STATE.lastThemeCssVars) return;
STATE.lastThemeCssVars = cssVars;
const themeTokens = computeThemeTokens();
for (const elements of STATE.nodeIdToElements.values()) {
try {
applyThemeToElements(themeTokens, elements);
elements?.iframe?.contentWindow?.postMessage({
type: "was-theme-update",
cssVars,
}, "*");
} catch (e) {}
}
}
function scheduleThemeUpdate() {
if (STATE.themeUpdatePending) return;
STATE.themeUpdatePending = true;
window.requestAnimationFrame(() => {
STATE.themeUpdatePending = false;
broadcastThemeUpdate();
});
}
function ensureThemeSyncRunning() {
if (STATE.themeSyncAttached) return;
STATE.themeSyncAttached = true;
// Initialize baseline
STATE.lastThemeCssVars = getThemeCssVars();
const onThemeMaybeChanged = () => scheduleThemeUpdate();
// Observe attribute changes; ComfyUI theme switching often toggles classes/attributes
try {
const observer = new MutationObserver(onThemeMaybeChanged);
if (document.documentElement) {
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme", "data-color-scheme"],
});
}
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style", "data-theme", "data-color-scheme"],
});
}
} catch (e) {}
// Fallback: periodic check (covers cases where theme changes via stylesheet updates without attribute mutation)
window.setInterval(() => {
try {
const cssVars = getThemeCssVars();
if (cssVars && cssVars !== STATE.lastThemeCssVars) {
scheduleThemeUpdate();
}
} catch (e) {}
}, 1500);
// Also update on focus/visibility (covers theme change while tab is backgrounded)
window.addEventListener("focus", onThemeMaybeChanged);
window.addEventListener("visibilitychange", onThemeMaybeChanged);
}
function getBasePath() {
return import.meta.url.substring(0, import.meta.url.lastIndexOf("/"));
}
async function initializeViews() {
if (STATE.viewsInitialized) return;
const basePath = getBasePath();
await loadAllViews();
await Promise.all([
initializeViewScripts(basePath),
loadPrismScripts(basePath),
]);
STATE.viewsInitialized = true;
}
initializeViews();
ensureThemeSyncRunning();
onViewsRefresh(() => {
refreshAllViewers();
});
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) {}
}
}
}
// Also push current theme to any existing iframes.
try {
broadcastThemeUpdate();
} catch (e) {}
}
function getActiveGraphNodes() {
const g = app?.graph || app?.canvas?.graph;
const nodes = g?._nodes || g?.nodes;
return Array.isArray(nodes) ? nodes : [];
}
function isViewerNode(node) {
try {
const nodeType = node?.comfyClass || node?.type || node?.constructor?.comfyClass || node?.constructor?.type;
return nodeType === NODE_NAME;
} catch (e) {
console.error("[WAS Viewer] isViewerNode error:", e);
return false;
}
}
/**
* Check if node is muted or bypassed
* LiteGraph modes: 0=ALWAYS, 1=ON_EVENT, 2=NEVER (muted), 3=ON_TRIGGER, 4=bypassed
*/
function isNodeDisabled(node) {
return node?.mode === 2 || node?.mode === 4;
}
function removeElementsByKey(key) {
try {
const elements = STATE.nodeIdToElements.get(key);
if (!elements) return;
diag(`removeElementsByKey: key=${key} directUrlLoaded=${elements?.directUrlLoaded}`);
elements.wrapper?.remove();
} catch (e) {
console.error("[WAS Viewer] removeElementsByKey error:", e);
} finally {
STATE.nodeIdToElements.delete(key);
}
}
function cleanupOrphanElements() {
try {
const nodes = getActiveGraphNodes();
// During hard refresh the graph may not be populated yet. If we have
// tracked elements but the graph reports zero viewer nodes, skip this
// cycle — otherwise we'd destroy iframes that will be needed as soon
// as the graph finishes loading, causing a create-destroy-create loop.
const viewerNodes = nodes.filter((n) => isViewerNode(n));
if (viewerNodes.length === 0 && STATE.nodeIdToElements.size > 0) return;
const activeIds = new Set(viewerNodes.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 checkForHangers() {
const canvas = app.canvas;
const canvasEl = canvas?.canvas;
if (!canvasEl) return;
const canvasRect = canvasEl.getBoundingClientRect();
const ds = canvas.ds;
const scale = ds?.scale ?? 1;
const offset = ds?.offset ?? [0, 0];
for (const [nodeId, elements] of STATE.nodeIdToElements.entries()) {
if (!elements?.wrapper) continue;
const node = app?.graph?.getNodeById(parseInt(nodeId));
if (!node) {
elements.wrapper.style.display = "none";
continue;
}
const titleH = node?.title_height ?? 30;
const margin = 10;
const expectedScreenX = (node.pos[0] + margin + offset[0]) * scale + canvasRect.left;
const expectedScreenY = (node.pos[1] + margin + titleH + offset[1]) * scale + canvasRect.top;
const scaledW = (node.size[0] - margin * 2) * scale;
const scaledH = (node.size[1] - titleH - margin * 2) * scale;
const isOffScreen =
(expectedScreenX + scaledW < canvasRect.left) ||
(expectedScreenX > canvasRect.right) ||
(expectedScreenY + scaledH < canvasRect.top) ||
(expectedScreenY > canvasRect.bottom);
if (isOffScreen) {
elements.wrapper.style.display = "none";
}
}
}
function startHangerCheck() {
if (STATE.hangerCheckIntervalId != null) return;
STATE.hangerCheckIntervalId = window.setInterval(() => checkForHangers(), 100);
}
function getContainer() {
if (STATE.container) return STATE.container;
const canvasEl = app.canvas?.canvas;
const canvasParent = canvasEl?.parentElement;
if (!canvasParent) {
return null;
}
if (getComputedStyle(canvasParent).position === "static") {
canvasParent.style.position = "relative";
}
const el = document.createElement("div");
el.id = "was-viewer-overlay";
el.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
overflow: hidden;
clip-path: inset(0);
`;
canvasParent.appendChild(el);
STATE.container = el;
function updateContainerBounds() {
if (!canvasEl || !el) return;
const rect = canvasEl.getBoundingClientRect();
const parentRect = canvasParent.getBoundingClientRect();
const left = rect.left - parentRect.left;
const top = rect.top - parentRect.top;
el.style.left = `${left}px`;
el.style.top = `${top}px`;
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
}
updateContainerBounds();
STATE.updateContainerBounds = updateContainerBounds;
const resizeObserver = new ResizeObserver(updateContainerBounds);
resizeObserver.observe(canvasEl);
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) {
return manualContent;
}
const connectedContent = getConnectedContent(node);
if (connectedContent) {
return connectedContent;
}
return "";
}
/**
* Check if the upstream node connected to "content" input is a specific
* ComfyUI class type (e.g. an OpenReel node). Returns the comfyClass string
* of the upstream node, or null if nothing is connected.
*/
function getUpstreamNodeClass(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);
return originNode?.comfyClass || originNode?.type || null;
}
function ensureElementsForNode(node) {
ensureCleanupRunning();
const key = String(node.id);
const existing = STATE.nodeIdToElements.get(key);
if (existing) return existing;
const container = getContainer();
if (!container) return null;
const theme = computeThemeTokens();
const wrapper = document.createElement("div");
wrapper.style.cssText = `
position: fixed;
display: flex;
flex-direction: column;
pointer-events: none;
transform-origin: 0 0;
background: ${theme.bg};
border-radius: 0 0 8px 8px;
overflow: hidden;
z-index: 10;
`;
const elements = {
wrapper,
contentWrapper: null,
controls: null,
iframe: null,
textarea: null,
typeLabel: null,
toggleAllBtn: null,
lastContentHash: null,
isEditing: false,
listEditContainer: null,
listTextareas: null,
mutedOverlay: null,
lowQualityOverlay: null,
isMuted: false,
};
const controls = createControlsBar(node, elements, {
getNodeContent,
setWidgetValue,
updateIframeContent,
buildIframeContent,
app,
onViewChange: handleViewChange,
});
wrapper.appendChild(controls);
elements.controls = controls;
const contentWrapper = document.createElement("div");
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-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-downloads");
iframe.setAttribute("allow", "fullscreen");
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;
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;
const mutedOverlay = document.createElement("div");
mutedOverlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: ${theme.bg};
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
border-radius: 0 0 8px 8px;
pointer-events: auto;
`;
mutedOverlay.innerHTML = `
<span style="font-size: 32px; opacity: 0.3;">🔇</span>
<span style="font-size: 12px; opacity: 0.5; font-family: sans-serif;">Node is muted/bypassed</span>
<span style="font-size: 10px; opacity: 0.3; font-family: sans-serif;">Unmute to load content</span>
`;
contentWrapper.appendChild(mutedOverlay);
elements.mutedOverlay = mutedOverlay;
const lowQualityOverlay = document.createElement("div");
lowQualityOverlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: ${theme.bg};
display: none;
align-items: center;
justify-content: center;
border-radius: 0 0 8px 8px;
pointer-events: auto;
`;
contentWrapper.appendChild(lowQualityOverlay);
elements.lowQualityOverlay = lowQualityOverlay;
wrapper.appendChild(contentWrapper);
elements.contentWrapper = contentWrapper;
document.body.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 || !elements?.wrapper) return;
const ds = canvas.ds;
const scale = ds?.scale ?? 1;
const offset = ds?.offset ?? [0, 0];
let isNodeVisible = true;
if (typeof canvas.isNodeVisible === 'function') {
isNodeVisible = canvas.isNodeVisible(node);
} else if (canvas.visible_area && node.size) {
const va = canvas.visible_area;
const nodeRight = node.pos[0] + node.size[0];
const nodeBottom = node.pos[1] + node.size[1];
isNodeVisible = !(
nodeRight < va[0] ||
node.pos[0] > va[0] + va[2] ||
nodeBottom < va[1] ||
node.pos[1] > va[1] + va[3]
);
}
const isCollapsed = !!node.flags?.collapsed;
if (!isNodeVisible || isCollapsed) {
elements.wrapper.style.display = "none";
return;
}
// Check for low quality rendering mode (zoomed out past threshold)
const isLowQuality = canvas.low_quality === true;
if (elements.lowQualityOverlay) {
if (isLowQuality) {
elements.iframe.style.display = "none";
elements.lowQualityOverlay.style.display = "flex";
} else {
elements.iframe.style.display = "block";
elements.lowQualityOverlay.style.display = "none";
}
}
const canvasRect = canvasEl.getBoundingClientRect();
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 margin = 10;
const widgetX = node.pos[0] + margin;
const widgetY = node.pos[1] + margin + titleHRaw;
const screenX = (widgetX + offset[0]) * scale + canvasRect.left;
const screenY = (widgetY + offset[1]) * scale + canvasRect.top;
const widgetW = node.size[0] - margin * 2;
const widgetH = node.size[1] - titleHRaw - margin * 2;
const nw = Math.max(0, Math.round(widgetW));
const nh = Math.max(0, Math.round(widgetH));
const hasArea = nw >= 2 && nh >= 2;
if (!hasArea) {
elements.wrapper.style.display = "none";
return;
}
elements.wrapper.style.position = 'fixed';
elements.wrapper.style.left = `${screenX}px`;
elements.wrapper.style.top = `${screenY}px`;
elements.wrapper.style.width = `${nw}px`;
elements.wrapper.style.height = `${nh}px`;
elements.wrapper.style.transform = `scale(${scale})`;
elements.wrapper.style.transformOrigin = '0 0';
elements.wrapper.style.display = "flex";
}
function processIframeQueue() {
if (STATE.iframeLoading || STATE.iframeLoadQueue.length === 0) return;
STATE.iframeLoading = true;
const { elements, html, needsBlobUrl, scriptData, directUrl } = STATE.iframeLoadQueue.shift();
if (!elements?.iframe) {
STATE.iframeLoading = false;
processIframeQueue();
return;
}
const onLoad = () => {
elements.iframe.removeEventListener("load", onLoad);
if (!directUrl && scriptData && scriptData.length > 0) {
try {
elements.iframe.contentWindow.postMessage({
type: 'was-inject-scripts',
scripts: scriptData
}, '*');
} catch (e) {
console.error('[WAS Viewer] Failed to inject scripts:', e);
}
}
STATE.iframeLoading = false;
setTimeout(processIframeQueue, 150);
};
elements.iframe.addEventListener("load", onLoad);
setTimeout(() => {
if (STATE.iframeLoading) {
STATE.iframeLoading = false;
processIframeQueue();
}
}, 3000);
// Clean up any previous blob URL
if (elements.lastBlobUrl) {
URL.revokeObjectURL(elements.lastBlobUrl);
elements.lastBlobUrl = null;
}
if (directUrl) {
// View provides its own URL — load directly via src (no srcdoc/blob wrapper).
if (elements.pendingSandbox && elements.iframe.getAttribute("sandbox") !== elements.pendingSandbox) {
elements.iframe.setAttribute("sandbox", elements.pendingSandbox);
}
elements.iframe.removeAttribute("srcdoc");
diag(`processIframeQueue: setting iframe.src to directUrl`);
elements.iframe.src = directUrl;
} else if (needsBlobUrl) {
if (elements.pendingSandbox && elements.iframe.getAttribute("sandbox") !== elements.pendingSandbox) {
elements.iframe.setAttribute("sandbox", elements.pendingSandbox);
}
elements.lastDirectUrlKey = null;
const blob = new Blob([html], { type: "text/html" });
elements.lastBlobUrl = URL.createObjectURL(blob);
elements.iframe.removeAttribute("srcdoc");
elements.iframe.src = elements.lastBlobUrl;
} else {
if (elements.pendingSandbox && elements.iframe.getAttribute("sandbox") !== elements.pendingSandbox) {
elements.iframe.setAttribute("sandbox", elements.pendingSandbox);
}
elements.lastDirectUrlKey = null;
elements.iframe.src = "";
elements.iframe.srcdoc = html;
}
}
function updateIframeContent(node, elements, forceView = null) {
if (elements.isEditing) return;
const content = getNodeContent(node, elements);
const contentHash = content ? content.length + "_" + content.slice(0, 100) : "__empty__";
// Check for manual view selection from the toolbar dropdown
let manualViewType = null;
if (!content && elements.viewSelector && elements.viewSelector.value) {
manualViewType = elements.viewSelector.value;
}
// Restore persisted view selection from view_state on reload
if (!content && !manualViewType) {
const vsWidget = node.widgets?.find(w => w.name === "view_state");
if (vsWidget) {
try {
const vs = JSON.parse(vsWidget.value || "{}");
if (vs._selectedView) {
manualViewType = vs._selectedView;
}
} catch {}
}
}
// When content is empty but the upstream node is a UI view provider (e.g.
// OpenReel), load the view in standalone mode so the user can work in it
// before the workflow has been executed. We use a special hash so this
// only runs once (not every frame).
let upstreamViewType = null;
if (!content && !manualViewType) {
const upstreamClass = getUpstreamNodeClass(node);
if (upstreamClass && upstreamClass.includes("OpenReel")) {
upstreamViewType = "openreel_video";
}
}
const effectiveHash = (manualViewType || upstreamViewType) ? ("_manual:" + (manualViewType || upstreamViewType)) : contentHash;
if (effectiveHash === elements.lastContentHash && !forceView) return;
elements.lastContentHash = effectiveHash;
const theme = computeThemeTokens();
let displayContent = content;
let contentType;
let currentView = forceView || elements.currentView;
if (isMultiviewContent(content)) {
const multiview = parseMultiviewContent(content);
if (multiview) {
if (!currentView || !multiview.views.find(v => v.name === currentView)) {
currentView = multiview.defaultView;
}
const viewData = multiview.views.find(v => v.name === currentView);
if (viewData) {
displayContent = viewData.displayContent;
}
elements.currentView = currentView;
elements.multiviewContent = content;
updateViewSelector(elements.viewSelector, content, currentView);
contentType = detectContentType(displayContent);
} else {
contentType = detectContentType(content);
}
} else {
// Manual view_type only applies when there's no content (standalone mode)
// When content exists, always use normal detection
if (!content && manualViewType) {
contentType = manualViewType;
} else {
contentType = upstreamViewType || detectContentType(content);
}
elements.currentView = manualViewType || null;
elements.multiviewContent = null;
// Update view selector for empty content (shows all views)
if (elements.viewSelector) {
updateViewSelector(elements.viewSelector, content, manualViewType);
}
}
if (elements.typeLabel) {
elements.typeLabel.textContent = `Type: ${getViewDisplayName(contentType)}`;
}
// Store desired sandbox attrs — applied in processIframeQueue right before
// loading new content, so we never change sandbox on an already-loaded iframe
// (which would trigger an unwanted browser reload).
elements.pendingSandbox = getViewSandboxAttributes(contentType);
elements.contentType = contentType;
updateControlsForUI(elements, isViewUI(contentType));
const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n";
const isListContent = displayContent && displayContent.includes(LIST_SEPARATOR);
let excluded = [];
const metaWidget = node.widgets?.find(w => w.name === "viewer_meta");
if (metaWidget?.value) {
try {
const meta = JSON.parse(metaWidget.value);
excluded = Array.isArray(meta.excluded) ? meta.excluded : [];
} catch {}
}
if (elements.toggleAllBtn) {
elements.toggleAllBtn.style.display = isListContent ? "block" : "none";
if (isListContent) {
elements.toggleAllBtn.textContent = excluded.length === 0 ? "☑" : "☐";
elements.toggleAllBtn.title = excluded.length === 0 ? "Uncheck All" : "Check All";
}
}
const savedViewState = getViewState(contentType, node);
const finalContent = injectViewState(contentType, displayContent, savedViewState);
let html;
const nodeId = String(node.id);
if (!displayContent && !manualViewType) {
html = 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, [], nodeId);
} else {
html = buildIframeContent(finalContent || '', contentType, theme, excluded, nodeId);
}
// Check if the view provides a direct URL (e.g. OpenReel app served by its own endpoint).
// Pass content even if empty — views like OpenReel can load standalone for manual use.
const directUrl = getViewDirectUrl(contentType, finalContent || '', theme);
// If the view provides a directUrl AND we already loaded it, send content
// updates via postMessage instead of reloading the iframe.
diag(`updateIframeContent: directUrl=${!!directUrl} directUrlLoaded=${!!elements.directUrlLoaded} nodeId=${String(node.id)} hasContent=${!!displayContent}`);
// For OpenReel: defer loading until we have actual content to avoid hard refresh race conditions
if (directUrl && !displayContent && !elements.directUrlLoaded && !manualViewType) {
diag(`updateIframeContent: OpenReel deferred - no content yet`);
// Show placeholder, don't load the app yet
const placeholderHtml = buildIframeContent("<p style='opacity:0.5;text-align:center;margin-top:40px;'>Waiting for video content...</p>", "html", theme, [], nodeId);
elements.iframe.srcdoc = placeholderHtml;
elements.lastContentHash = effectiveHash;
return;
}
if (directUrl && elements.directUrlLoaded) {
diag(`updateIframeContent: DEDUP`);
if (displayContent && elements.iframe?.contentWindow) {
const contentMsg = getViewContentMessage(contentType, displayContent);
if (contentMsg) {
try {
elements.iframe.contentWindow.postMessage(contentMsg, '*');
} catch (e) {}
}
}
elements.lastContentHash = effectiveHash;
return;
}
const needsBlobUrl = !directUrl && (displayContent || manualViewType) && (
viewNeedsBlobUrl(contentType) ||
(contentType === "html" && displayContent && (
displayContent.includes("WebAssembly") ||
displayContent.includes("wasm") ||
displayContent.includes("createUnityInstance") ||
displayContent.includes("ServiceWorker") ||
displayContent.includes("SharedArrayBuffer")
))
);
const scriptData = (displayContent || manualViewType) ? getViewScriptData(contentType, finalContent || '') : [];
// Mark that a directUrl load has been queued.
if (directUrl) {
diag(`updateIframeContent: queuing directUrl load`);
elements.directUrlLoaded = true;
}
STATE.iframeLoadQueue = STATE.iframeLoadQueue.filter(item => item.elements !== elements);
STATE.iframeLoadQueue.push({ elements, html, needsBlobUrl, scriptData, directUrl });
processIframeQueue();
}
/**
* Handle view change from dropdown selector
* @param {object} node - ComfyUI node
* @param {object} elements - DOM elements container
* @param {string} viewName - Name of view to switch to
*/
function handleViewChange(node, elements, viewName) {
// Persist selected view into view_state so it survives refresh/reload
const vsWidget = node.widgets?.find(w => w.name === "view_state");
if (vsWidget) {
try {
const vs = JSON.parse(vsWidget.value || "{}");
vs._selectedView = viewName || null;
vsWidget.value = JSON.stringify(vs);
} catch {}
}
if (elements.multiviewContent) {
elements.lastContentHash = "";
updateIframeContent(node, elements, viewName);
return;
}
// Manual view selection on empty nodes
elements.lastContentHash = "";
elements.directUrlLoaded = false;
updateIframeContent(node, elements);
}
window.addEventListener("message", (event) => {
if (event.data?.type === "was-viewer-toggle") {
const { idx, checked, nodeId } = event.data;
if (!nodeId) {
return;
}
const node = app.graph?.getNodeById(parseInt(nodeId));
if (node) {
const metaWidget = node.widgets?.find(w => w.name === "viewer_meta");
if (metaWidget) {
let meta = { lastInputHash: "", excluded: [] };
try {
meta = JSON.parse(metaWidget.value || "{}");
if (!Array.isArray(meta.excluded)) meta.excluded = [];
} catch {}
if (checked) {
meta.excluded = meta.excluded.filter(i => i !== idx);
} else {
if (!meta.excluded.includes(idx)) meta.excluded.push(idx);
}
metaWidget.value = JSON.stringify(meta);
node.setDirtyCanvas?.(true, true);
}
}
} else if (event.data?.type === "openreel-ready") {
// The OpenReel app inside the iframe is ready to receive content.
// Re-send the current content so video gets imported even if the
// earlier postMessage arrived before the React listener was active.
for (const [nId, elements] of STATE.nodeIdToElements.entries()) {
if (elements.iframe && elements.iframe.contentWindow === event.source) {
const node = app.graph?.getNodeById(parseInt(nId));
if (node && elements.contentType) {
const content = getNodeContent(node, elements);
if (content) {
const contentMsg = getViewContentMessage(elements.contentType, content);
if (contentMsg) {
try {
elements.iframe.contentWindow.postMessage(contentMsg, '*');
} catch (e) {}
}
}
}
break;
}
}
} else {
const messageType = event.data?.type;
const nodeId = event.data?.nodeId;
if (messageType && !nodeId) {
for (const [nId, elements] of STATE.nodeIdToElements.entries()) {
if (elements.iframe && elements.iframe.contentWindow === event.source) {
const node = app.graph?.getNodeById(parseInt(nId));
if (node) {
handleViewMessage(messageType, event.data, node, app, event.source);
}
break;
}
}
} else if (messageType && nodeId) {
const node = app.graph?.getNodeById(parseInt(nodeId));
if (node) {
handleViewMessage(messageType, event.data, node, app, event.source);
}
}
}
});
app.registerExtension({
name: EXT_NAME,
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_NAME) return;
ensureCleanupRunning();
startHangerCheck();
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;
}
let metaWidget = this.widgets?.find((w) => w.name === "viewer_meta");
if (!metaWidget) {
metaWidget = this.addWidget("text", "viewer_meta", JSON.stringify({ lastInputHash: "", excluded: [] }), () => {});
}
if (metaWidget) {
metaWidget.type = "hidden";
metaWidget.computeSize = () => [0, -4];
metaWidget.serializeValue = () => metaWidget.value;
}
let viewStateWidget = this.widgets?.find((w) => w.name === "view_state");
if (!viewStateWidget) {
viewStateWidget = this.addWidget("text", "view_state", "{}", () => {});
}
if (viewStateWidget) {
viewStateWidget.type = "hidden";
viewStateWidget.computeSize = () => [0, -4];
viewStateWidget.serializeValue = () => viewStateWidget.value;
}
for (const w of this.widgets || []) {
w.type = "hidden";
w.computeSize = () => [0, -4];
w.draw = () => {};
}
this.setDirtyCanvas?.(true, true);
} 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);
if (elements) {
updateElementsRect(this, elements);
if (!elements.lastContentHash) {
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 = STATE.nodeIdToElements.get(String(this.id));
if (elements) {
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 imagesInputIndex = this.inputs?.findIndex((i) => i.name === "images");
const isRelevantInput = isInputSlot && (contentInputIndex === slotIndex || imagesInputIndex === slotIndex);
if (isRelevantInput) {
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 = "";
}
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 viewStateWidget = this.widgets?.find((w) => w.name === "view_state");
if (viewStateWidget) {
viewStateWidget.type = "hidden";
viewStateWidget.computeSize = () => [0, -4];
if (data?.widgets_values) {
const idx = this.widgets.findIndex(w => w.name === "view_state");
if (idx >= 0 && data.widgets_values[idx] !== undefined) {
viewStateWidget.value = data.widgets_values[idx];
}
}
}
const node = this;
setTimeout(() => {
try {
const elements = STATE.nodeIdToElements.get(String(node.id));
if (elements) {
elements.lastContentHash = "";
updateIframeContent(node, elements);
}
} catch (e) {
console.error("[WAS Viewer] onConfigure delayed update error:", e);
}
}, 100);
} 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 {
const displayText = message?.text?.[0];
const sourceContent = message?.source_content?.[0] || "";
const sourceContentHash = message?.content_hash?.[0] || "";
const inputHash = message?.input_hash?.[0] || "";
const viewStateWidget = this.widgets?.find(w => w.name === "view_state");
// Check if input changed and clear stale _output if so
let inputChanged = false;
if (inputHash && viewStateWidget) {
try {
const viewState = JSON.parse(viewStateWidget.value || "{}");
const storedHash = viewState._input_hash || "";
if (storedHash && storedHash !== inputHash) {
inputChanged = true;
for (const key of Object.keys(viewState)) {
if (key.endsWith("_output")) {
delete viewState[key];
}
}
console.log("[WAS Viewer] Input changed, cleared stale view outputs");
}
viewState._input_hash = inputHash;
viewStateWidget.value = JSON.stringify(viewState);
} catch {}
}
let hasViewOutput = false;
if (viewStateWidget?.value && !inputChanged) {
try {
const viewState = JSON.parse(viewStateWidget.value);
hasViewOutput = Object.keys(viewState).some(k => k.endsWith("_output") && viewState[k]);
if (hasViewOutput) {
let modified = false;
for (const key of Object.keys(viewState)) {
if (key.endsWith("_output") && viewState[key]) {
const valLen = typeof viewState[key] === "string" ? viewState[key].length : JSON.stringify(viewState[key]).length;
if (valLen > 10000) {
delete viewState[key];
modified = true;
}
}
}
if (modified) {
viewStateWidget.value = JSON.stringify(viewState);
console.log("[WAS Viewer] Cleared large output data from view_state after execution.");
}
}
} catch {}
}
if (hasViewOutput) {
return r;
}
const metaWidget = this.widgets?.find(w => w.name === "viewer_meta");
let meta = { lastInputHash: "", excluded: [] };
try {
meta = JSON.parse(metaWidget?.value || "{}");
if (!meta.lastInputHash) meta.lastInputHash = "";
if (!Array.isArray(meta.excluded)) meta.excluded = [];
} catch {}
if (sourceContentHash && meta.lastInputHash === sourceContentHash) {
return r;
}
if (sourceContent) {
meta.lastInputHash = sourceContentHash;
meta.excluded = [];
if (metaWidget) {
metaWidget.value = JSON.stringify(meta);
}
setWidgetValue(this, "manual_content", sourceContent);
const elements = STATE.nodeIdToElements.get(String(this.id));
if (elements) {
elements.lastContentHash = "";
updateIframeContent(this, elements);
}
} else if (displayText !== undefined && displayText !== null && displayText !== "") {
setWidgetValue(this, "manual_content", String(displayText));
const elements = STATE.nodeIdToElements.get(String(this.id));
if (elements) {
elements.lastContentHash = "";
updateIframeContent(this, elements);
}
}
} 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 {
if (!data.widgets_values) {
data.widgets_values = [];
}
const manualWidget = this.widgets?.find((w) => w.name === "manual_content");
if (manualWidget) {
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 || "";
}
}
const viewStateWidget = this.widgets?.find((w) => w.name === "view_state");
if (viewStateWidget) {
const idx = this.widgets.findIndex(w => w.name === "view_state");
if (idx >= 0) {
while (data.widgets_values.length <= idx) {
data.widgets_values.push(null);
}
let viewStateValue = "{}";
try {
const vs = JSON.parse(viewStateWidget.value || "{}");
for (const key of Object.keys(vs)) {
if (key.endsWith("_output") && vs[key]) {
const valLen = typeof vs[key] === "string" ? vs[key].length : JSON.stringify(vs[key]).length;
if (valLen > 10000) {
delete vs[key];
}
}
}
viewStateValue = JSON.stringify(vs);
} catch {
viewStateValue = viewStateWidget.value || "{}";
}
data.widgets_values[idx] = viewStateValue;
}
}
} catch (e) {
console.error("[WAS Viewer] serialize error:", e);
}
return data;
};
},
});