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("
No content. Click Edit to add content or connect a STRING input.
", "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 = ` 🔇 Node is muted/bypassed Unmute to load content `; 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("No content. Click Edit to add content or connect a STRING input.
", "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("Waiting for video content...
", "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; }; }, });