/** * View Loader for ComfyUI Viewer * Dynamically loads and manages view extensions from the /views/ directory. * * Views are loaded from view_manifest.js which lists available view files. * Extensions can register additional views by calling registerView() after load. */ import { VIEW_FILES } from "./view_manifest.js"; const VIEW_REGISTRY = new Map(); let viewsLoaded = false; let loadPromise = null; /** * Get the base path for view modules * @returns {string} */ function getViewsBasePath() { const basePath = import.meta.url.substring(0, import.meta.url.lastIndexOf("/")); return basePath; } /** * Register a view module * @param {object} viewModule - View module with static methods */ export function registerView(viewModule) { if (!viewModule.id) { console.error("[WAS Viewer] View module missing id:", viewModule); return; } VIEW_REGISTRY.set(viewModule.id, viewModule); console.log(`[WAS Viewer] Registered view: ${viewModule.id}`); } /** * Get a registered view by id * @param {string} id * @returns {object|null} */ export function getView(id) { return VIEW_REGISTRY.get(id) || null; } /** * Get all registered views * @returns {Map} */ export function getAllViews() { return VIEW_REGISTRY; } /** * Load all view modules from the views directory * @returns {Promise} */ export async function loadAllViews() { if (viewsLoaded) return; if (loadPromise) return loadPromise; loadPromise = (async () => { const basePath = getViewsBasePath(); // Load views from manifest for (const file of VIEW_FILES) { try { const module = await import(`${basePath}/${file}`); if (module.default) { registerView(module.default); } } catch (e) { console.error(`[WAS Viewer] Failed to load view module: ${file}`, e); } } // Load extension views from extension_views.json (auto-generated by installer) try { const response = await fetch(`${basePath}/extension_views.json`); if (response.ok) { const extensionViews = await response.json(); for (const file of extensionViews) { if (VIEW_FILES.includes(file)) continue; // Already loaded from manifest try { console.log(`[WAS Viewer] Loading extension view: ${file}`); const module = await import(`${basePath}/${file}`); if (module.default) { registerView(module.default); console.log(`[WAS Viewer] Loaded extension view: ${file}`); } else { console.warn(`[WAS Viewer] Extension view ${file} has no default export`); } } catch (e) { console.warn(`[WAS Viewer] Extension view ${file} failed to load:`, e.message); } } } } catch (e) { // extension_views.json doesn't exist yet - no extensions installed console.log(`[WAS Viewer] No extension_views.json found (no extensions installed)`); } viewsLoaded = true; console.log(`[WAS Viewer] Loaded ${VIEW_REGISTRY.size} view modules`); })(); return loadPromise; } /** * Initialize all view scripts * @param {string} basePath - Base path for scripts (web folder) * @returns {Promise} */ export async function initializeViewScripts(basePath) { const promises = []; for (const [id, view] of VIEW_REGISTRY) { if (view.loadScripts) { promises.push( view.loadScripts(basePath).catch(e => { console.error(`[WAS Viewer] Failed to load scripts for view: ${id}`, e); }) ); } } await Promise.all(promises); } /** * Detect the best view for content * @param {string} content - Content to analyze * @returns {string} - View id */ export function detectContentType(content) { if (!content || typeof content !== "string") return "text"; const trimmed = content.trim(); let bestView = "text"; let bestScore = 0; const sortedViews = Array.from(VIEW_REGISTRY.values()) .sort((a, b) => (b.priority || 0) - (a.priority || 0)); for (const view of sortedViews) { if (view.detect) { const score = view.detect(trimmed); if (score > bestScore) { bestScore = score; bestView = view.id; } } } return bestScore >= 2 ? bestView : "text"; } /** * Render content using the appropriate view * @param {string} content - Content to render * @param {string} contentType - Content type id * @param {object} theme - Theme tokens * @returns {string} - Rendered HTML */ export function renderContent(content, contentType, theme) { const view = VIEW_REGISTRY.get(contentType); if (view && view.render) { return view.render(content, theme); } const textView = VIEW_REGISTRY.get("text"); if (textView && textView.render) { return textView.render(content, theme); } return `
${content}
`; } /** * Get styles for a view type * @param {string} contentType * @param {object} theme * @returns {string} */ export function getViewStyles(contentType, theme) { const view = VIEW_REGISTRY.get(contentType); if (view && view.getStyles) { return view.getStyles(theme); } return ""; } /** * Get scripts for a view type (returns HTML script tags - legacy) * @param {string} contentType * @param {string} content * @returns {string} */ export function getViewScripts(contentType, content) { const view = VIEW_REGISTRY.get(contentType); if (view && view.getScripts) { return view.getScripts(content); } return ""; } /** * Get raw script data for postMessage injection * @param {string} contentType * @param {string} content * @returns {Array<{code: string, init?: string}>} Array of scripts to inject */ export function getViewScriptData(contentType, content) { const view = VIEW_REGISTRY.get(contentType); if (view && view.getScriptData) { return view.getScriptData(content); } return []; } /** * Check if a view is ready (scripts loaded) * @param {string} contentType * @returns {boolean} */ export function isViewReady(contentType) { const view = VIEW_REGISTRY.get(contentType); if (view && view.isReady) { return view.isReady(); } return true; } /** * Check if a view uses base iframe styles * @param {string} contentType * @returns {boolean} */ export function viewUsesBaseStyles(contentType) { const view = VIEW_REGISTRY.get(contentType); if (view && typeof view.usesBaseStyles === 'function') { return view.usesBaseStyles(); } return true; // Default: use base styles } /** * Check if a view is a UI (interactive application). * UI views hide Edit/Clear/Download buttons. * @param {string} contentType * @returns {boolean} */ export function isViewUI(contentType) { const view = VIEW_REGISTRY.get(contentType); return view?.isUI === true; } /** * Default sandbox attributes for the Content Viewer iframe. */ const DEFAULT_SANDBOX = "allow-scripts allow-forms allow-popups allow-modals allow-pointer-lock allow-downloads"; /** * Get sandbox attributes for the Content Viewer iframe based on the current view. * Views can declare additional sandbox permissions via a static getSandboxAttributes() method. * @param {string} contentType * @returns {string} sandbox attribute value */ export function getViewSandboxAttributes(contentType) { const view = VIEW_REGISTRY.get(contentType); if (view && typeof view.getSandboxAttributes === 'function') { return view.getSandboxAttributes(); } return DEFAULT_SANDBOX; } /** * Check if a view requires a blob URL instead of srcdoc for the outer iframe. * Views that need same-origin access (e.g. to make fetch calls to ComfyUI API) * must use blob URLs because srcdoc iframes always have a null origin. * @param {string} contentType * @returns {boolean} */ export function viewNeedsBlobUrl(contentType) { const view = VIEW_REGISTRY.get(contentType); if (view && typeof view.needsBlobUrl === 'function') { return view.needsBlobUrl(); } return false; } /** * Get a direct URL for the outer Content Viewer iframe. * Views that serve their own app (e.g. OpenReel) can provide a URL so the * outer iframe loads it via src= instead of building HTML with srcdoc/blob. * This avoids nested iframe issues entirely. * @param {string} contentType * @param {string} content - The raw display content (may contain JSON data) * @param {object} theme - Theme tokens * @returns {string|null} - URL to load, or null to use normal rendering */ export function getViewDirectUrl(contentType, content, theme) { const view = VIEW_REGISTRY.get(contentType); if (view && typeof view.getDirectUrl === 'function') { return view.getDirectUrl(content, theme); } return null; } /** * Build a postMessage payload for sending updated content to an already-loaded * directUrl iframe. Views that support this can update their content without * a full iframe reload. * @param {string} contentType * @param {string} content - The raw display content * @returns {object|null} - Message object to postMessage, or null if unsupported */ export function getViewContentMessage(contentType, content) { const view = VIEW_REGISTRY.get(contentType); if (view && typeof view.getContentMessage === 'function') { return view.getContentMessage(content); } return null; } /** * Get display name for a view type * @param {string} contentType * @returns {string} */ export function getViewDisplayName(contentType) { const view = VIEW_REGISTRY.get(contentType); return view?.displayName || "Unknown"; } /** * Refresh callback registry for views that load scripts asynchronously */ const refreshCallbacks = new Set(); export function onViewsRefresh(callback) { refreshCallbacks.add(callback); } export function triggerViewsRefresh() { for (const callback of refreshCallbacks) { try { callback(); } catch (e) { console.error("[WAS Viewer] Refresh callback error:", e); } } } /** * Find a view that handles a specific message type * @param {string} messageType - The message type from postMessage * @returns {object|null} - View module or null */ export function getViewForMessageType(messageType) { for (const [id, view] of VIEW_REGISTRY) { if (view.getMessageTypes && view.getMessageTypes().includes(messageType)) { return view; } } return null; } /** * Get raw content from a node (checks manual_content widget first, then connected content) * @param {object} node - The LiteGraph node * @returns {string} - Raw content string */ function getNodeRawContent(node) { // Check manual_content widget const manualWidget = node.widgets?.find(w => w.name === "manual_content"); if (manualWidget?.value) { return manualWidget.value; } return ""; } /** * Handle a message from an iframe by routing to the appropriate view * Automatically extracts view-specific content from multiview payloads. * * @param {string} messageType - The message type * @param {object} data - The message data * @param {object} node - The LiteGraph node * @param {object} app - The ComfyUI app instance * @param {Window} iframeSource - The iframe's contentWindow that sent the message * @returns {boolean} - True if message was handled */ export function handleViewMessage(messageType, data, node, app, iframeSource = null) { const view = getViewForMessageType(messageType); if (view && view.handleMessage) { // Get raw content and extract view-specific content if multiview const rawContent = getNodeRawContent(node); const viewContent = extractViewContent(rawContent, view.id); // Augment data with extracted content for the view const augmentedData = { ...data, _viewContent: viewContent, // View-specific content (multiview extracted) _rawContent: rawContent, // Original raw content }; return view.handleMessage(messageType, augmentedData, node, app, iframeSource); } return false; } /** * Get view-specific state from node for a content type * @param {string} contentType - The content type * @param {object} node - The LiteGraph node * @returns {object|null} - State object or null */ export function getViewState(contentType, node) { const view = VIEW_REGISTRY.get(contentType); if (view && view.getStateFromWidget) { return view.getStateFromWidget(node); } return null; } /** * Inject saved state into content for a content type * @param {string} contentType - The content type * @param {string} content - Original content * @param {object} state - State to inject * @returns {string} - Modified content */ export function injectViewState(contentType, content, state) { if (!state) return content; const view = VIEW_REGISTRY.get(contentType); if (view && view.injectState) { return view.injectState(content, state); } return content; } /** * Strip content marker from content if present * @param {string} content - Content that may have a marker prefix * @returns {{ content: string, view: object|null }} - Stripped content and matched view */ export function stripContentMarker(content) { if (!content || typeof content !== 'string') { return { content: content || '', view: null }; } for (const [id, view] of VIEW_REGISTRY) { if (view.getContentMarker) { const marker = view.getContentMarker(); if (marker && content.startsWith(marker)) { return { content: content.slice(marker.length), view: view }; } } } return { content, view: null }; } /** * Multi-view marker for content that can be displayed by multiple views */ export const MULTIVIEW_MARKER = "$WAS_MULTIVIEW$"; /** * Check if content is a multi-view payload * @param {string} content - Content to check * @returns {boolean} */ export function isMultiviewContent(content) { return content && typeof content === 'string' && content.startsWith(MULTIVIEW_MARKER); } /** * Parse multi-view payload and return view data * @param {string} content - Multi-view content with marker * @returns {{ defaultView: string, views: Array<{name: string, displayContent: string}> }|null} */ export function parseMultiviewContent(content) { if (!isMultiviewContent(content)) return null; try { const jsonStr = content.slice(MULTIVIEW_MARKER.length); const data = JSON.parse(jsonStr); if (data.type !== 'multiview' || !Array.isArray(data.views)) { return null; } return { defaultView: data.default_view, views: data.views.map(v => ({ name: v.name, priority: v.priority, displayContent: v.display_content, contentHash: v.content_hash, })), }; } catch (e) { console.error('[WAS Viewer] Failed to parse multiview content:', e); return null; } } /** * Get display content for a specific view from multi-view payload * @param {string} content - Multi-view content * @param {string} viewName - Name of view to get content for * @returns {string|null} - View-specific display content or null */ export function getMultiviewContent(content, viewName) { const parsed = parseMultiviewContent(content); if (!parsed) return null; const view = parsed.views.find(v => v.name === viewName); return view ? view.displayContent : null; } /** * Get list of available views from multi-view payload * @param {string} content - Multi-view content * @returns {Array<{name: string, displayName: string}>} */ export function getMultiviewOptions(content) { const parsed = parseMultiviewContent(content); if (!parsed) return []; return parsed.views.map(v => ({ name: v.name, displayName: getViewDisplayName(v.name), priority: v.priority, })); } /** * Extract view-specific content from raw content (handles multiview automatically) * This is the centralized function that view extensions should NOT need to call - * the viewer system uses this to provide clean content to views. * * @param {string} rawContent - Raw content that may be multiview or single-view * @param {string} viewName - Name of the view to extract content for * @returns {string} - View-specific content, or original content if not multiview */ export function extractViewContent(rawContent, viewName) { if (!rawContent || typeof rawContent !== 'string') { return rawContent || ''; } // Check if it's multiview content if (!isMultiviewContent(rawContent)) { return rawContent; } // Extract view-specific content from multiview payload const viewContent = getMultiviewContent(rawContent, viewName); if (viewContent) { return viewContent; } // Fallback: return first available view's content const parsed = parseMultiviewContent(rawContent); if (parsed && parsed.views.length > 0) { return parsed.views[0].displayContent; } return rawContent; } /** * Parse content with marker and return parsed data * @param {string} content - Content that may have a marker prefix * @returns {object|null} - Parsed JSON data or null */ export function parseMarkedContent(content) { const { content: stripped, view } = stripContentMarker(content); if (!stripped) return null; try { return JSON.parse(stripped); } catch { return null; } }