Make the pop-out pair with its own tab and keep its connection

Each ComfyUI tab talks to its viewer on a session channel. Pings run only while
connected, status follows pings, and a hidden glass keeps feeding the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-11-04 08:43:59 -08:00
co-authored by Claude Opus 5.5
parent 305a163b10
commit 76bb34da1d
8 changed files with 472 additions and 330 deletions
+10 -6
View File
@@ -42,13 +42,16 @@ export class MagnifyGlass {
/** Last known mouse position for better initial positioning */
lastKnownMousePosition: { x: number; y: number };
/** Render loop ID; the loop runs while the glass is active and visible */
/** Render loop ID; the loop runs while the glass is active and visible, or feeds the pop-out */
private animationLoopId: number | null;
constructor() {
this.config = new ConfigManager();
this.state = new MagnifierState();
this.popOutManager = new PopOutManager();
this.popOutManager = new PopOutManager(() => ({
glass: !this.state.active ? 'off' : this.state.isPreviewHidden ? 'hidden' : 'on',
inspector: !!window.infoPanelManager
}), () => this.updateMagnifiedView());
this.ui = new UiManager(
this.config,
this.state,
@@ -171,10 +174,9 @@ export class MagnifyGlass {
/**
* Set glass preview visibility WITHOUT affecting info panel.
* This is used by the toggle-glass hover control button.
* When hidden, the render loop stops completely to save performance.
* When shown, the render loop resumes.
* A hidden preview stops the render loop unless the pop-out shows it.
*
* @param visible - true to show and enable rendering, false to hide and disable
* @param visible - true to show the glass, false to hide it
*/
setGlassPreviewActive(visible: boolean): void {
this.state.isPreviewHidden = !visible;
@@ -255,7 +257,9 @@ export class MagnifyGlass {
if (this.animationLoopId !== null) return; // Already running
const animate = () => {
if (!this.state.active || this.state.isPreviewHidden || !this.renderer || !this.litegraphCanvas) {
// A hidden glass still feeds the pop-out, which may be the only view of it
const hidden = this.state.isPreviewHidden && !this.popOutManager.isPopOutOpen();
if (!this.state.active || hidden || !this.renderer || !this.litegraphCanvas) {
this.animationLoopId = null;
return;
}
+109 -116
View File
@@ -9,15 +9,6 @@ import { BROADCAST_CHANNEL_NAME } from '../shared/constants';
import { Logger } from '../shared/logger';
import { WidgetSyncManager } from '../info-panel/widget-editors/WidgetSyncManager';
/**
* Message types for BroadcastChannel communication
*/
interface PopOutMessage {
type: 'frame' | 'config' | 'info' | 'close' | 'ping' | 'pong' | 'node-select' | 'nodes-list' | 'request-nodes' | 'zoom-node' | 'widget-edit' | 'button-click';
data?: string | Partial<PopOutConfig> | PopOutInfo | NodeListData | number | WidgetEditData;
timestamp?: number;
}
/**
* Widget edit command data from pop-out viewer
*/
@@ -35,12 +26,13 @@ interface NodeListData {
sortBy: 'title' | 'execOrder' | 'id';
}
interface PopOutConfig {
glassSize: number;
borderColor: string;
borderWidth: number;
glassShape: string;
theme?: string;
export interface PopOutStatus {
glass: 'on' | 'hidden' | 'off';
inspector: boolean;
}
interface PopOutConfig extends PopOutStatus {
theme: 'dark' | 'light';
}
/**
@@ -73,9 +65,27 @@ interface PopOutInfo {
};
}
/** Messages on the session's channel, in both directions */
type PopOutMessage =
| { type: 'ping' }
| { type: 'pong' }
| { type: 'close' }
| { type: 'visibility'; data: boolean }
| { type: 'frame'; data: string }
| { type: 'config'; data: PopOutConfig }
| { type: 'info'; data?: PopOutInfo }
| { type: 'nodes-list'; data: NodeListData }
| { type: 'request-nodes'; data: NodeListData['sortBy'] }
| { type: 'node-select'; data: number }
| { type: 'zoom-node'; data: number }
| { type: 'widget-edit'; data: WidgetEditData }
| { type: 'button-click'; data: { nodeId: number; widgetName: string } };
const SESSION_KEY = 'magnifyglass-popout-session';
/**
* PopOutManager class.
* Handles opening, closing, and syncing with an external browser tab.
* Handles opening, closing, and syncing with the viewer window of this tab's session.
*/
export class PopOutManager {
private channel: BroadcastChannel | null = null;
@@ -84,22 +94,34 @@ export class PopOutManager {
public onStateChange: ((isOpen: boolean) => void) | null = null;
public onNodeSelect: ((nodeId: number) => void) | null = null;
public onWidgetEdit: (() => void) | null = null;
private lastPongTime: number = 0;
private pingInterval: number | null = null;
// Pings since the last pong
private pendingPings: number = 0;
// Closed from here: an announcing viewer waits until open() is called again
private dismissed: boolean = false;
private viewerHidden: boolean = false;
private session: string;
private viewerUrl: string;
// The theme last sent to the viewer, null until it connects
private sentTheme: string | null = null;
// The config last sent to the viewer, null until it connects
private sentConfig: string | null = null;
private readonly onPageHide = () => {
if (this.isOpen) this.sendMessage({ type: 'close' });
};
/** Throttle frame sending to ~30fps */
private lastFrameTime: number = 0;
private readonly FRAME_INTERVAL = 33; // ~30fps
/** Connection timeout (ms) */
private readonly CONNECTION_TIMEOUT = 5000;
/** Unanswered pings, one a second, before the viewer counts as gone */
private readonly PING_LIMIT = 5;
constructor() {
constructor(private readonly getStatus: () => PopOutStatus, private readonly onConnect: () => void) {
// sessionStorage survives a reload, so the viewer finds this tab again after F5
this.session = sessionStorage.getItem(SESSION_KEY) ?? Math.random().toString(36).slice(2);
sessionStorage.setItem(SESSION_KEY, this.session);
this.viewerUrl = this.getViewerUrl();
this.initChannel();
window.addEventListener('pagehide', this.onPageHide);
}
/**
@@ -108,21 +130,19 @@ export class PopOutManager {
private getViewerUrl(): string {
const url = new URL(/* @vite-ignore */ '../popout-viewer.html', import.meta.url);
url.searchParams.set('v', __MAGNIFY_VERSION__);
url.searchParams.set('session', this.session);
return url.href;
}
/**
* Initialize the BroadcastChannel.
* Open this session's BroadcastChannel; other ComfyUI tabs have their own.
*/
private initChannel(): void {
try {
this.channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
this.channel = new BroadcastChannel(`${BROADCAST_CHANNEL_NAME}:${this.session}`);
this.channel.onmessage = (event: MessageEvent<PopOutMessage>) => {
this.handleMessage(event.data);
};
Logger.debug('[PopOut] BroadcastChannel initialized');
// Start pinging immediately to detect if viewer is already open (e.g. after reload)
this.startPing();
} catch (e) {
Logger.error('[PopOut] Failed to create BroadcastChannel:', e);
}
@@ -133,33 +153,29 @@ export class PopOutManager {
*/
private handleMessage(message: PopOutMessage): void {
switch (message.type) {
case 'pong': {
this.lastPongTime = Date.now();
if (!this.isOpen) {
case 'pong':
// A pong answers a ping, or announces a viewer that is waiting for this tab
this.pendingPings = 0;
if (!this.isOpen && !this.dismissed) {
this.isOpen = true;
this.sentTheme = null;
Logger.debug('[PopOut] Viewer tab connected');
this.viewerHidden = false;
this.sentConfig = null;
this.startPing();
if (this.onStateChange) this.onStateChange(true);
this.onConnect();
}
// The viewer follows the palette's light or dark, which the frontend keeps as body.dark-theme
const theme = document.body.classList.contains('dark-theme') ? 'dark' : 'light';
if (theme !== this.sentTheme) this.sendConfig({ theme });
if (this.isOpen) this.syncConfig();
break;
}
case 'close':
this.isOpen = false;
this.popOutWindow = null;
// Don't stop pinging, we might want to reconnect if user re-opens externally
// or if we just want to keep checking.
// However, original logic stopped ping. Let's keep pinging to auto-detect return.
// this.stopPing();
Logger.debug('[PopOut] Viewer tab closed');
if (this.onStateChange) this.onStateChange(false);
// The viewer window closed or reloaded; a reloaded one announces itself again
this.disconnect();
break;
case 'visibility':
this.viewerHidden = message.data;
break;
case 'request-nodes':
// Popout is requesting the node list for dropdown
Logger.debug('[PopOut] Received request-nodes:', message.data);
this.handleNodeListRequest(message.data as 'title' | 'execOrder' | 'id');
this.handleNodeListRequest(message.data);
break;
case 'node-select':
// Popout selected a node, notify main window
@@ -179,11 +195,11 @@ export class PopOutManager {
break;
case 'widget-edit':
// Popout is sending a widget value edit
this.handleWidgetEdit(message.data as WidgetEditData);
this.handleWidgetEdit(message.data);
break;
case 'button-click':
// Popout is requesting a button widget callback invocation
this.handleButtonClick(message.data as { nodeId: number; widgetName: string });
this.handleButtonClick(message.data);
break;
}
}
@@ -249,44 +265,45 @@ export class PopOutManager {
* Open the pop-out viewer in a popup window.
*/
open(): void {
// If window exists and is open, focus it
if (this.popOutWindow && !this.popOutWindow.closed) {
this.popOutWindow.focus();
this.dismissed = false;
if (this.isOpen) {
this.popOutWindow?.focus();
return;
}
// A named popup window: a tab would hide ComfyUI and pause its render loop
// A named popup window: a tab would hide ComfyUI and pause its render loop.
// Opening it moves a viewer from another tab to this session, or reloads a disconnected one.
this.popOutWindow = window.open(this.viewerUrl, 'MagnifyGlassPopout', 'popup,width=1100,height=720');
if (!this.popOutWindow) {
Logger.error('[PopOut] Failed to open the viewer - popup may be blocked');
return;
}
// Start pinging to detect when viewer is ready
this.startPing();
Logger.debug('[PopOut] Opening viewer tab...');
}
/**
* Close the pop-out viewer tab.
*/
close(): void {
if (!this.isOpen && !this.popOutWindow) return;
// A window this tab is not connected to may now belong to another tab's session
if (!this.isOpen) return;
// update state immediately for instant UI feedback
this.dismissed = true;
this.sendMessage({ type: 'close' });
// Without a handle, e.g. after a reload, the viewer stays and waits for open()
this.popOutWindow?.close();
this.popOutWindow = null;
this.disconnect();
}
/**
* Mark the viewer as gone. It announces itself again while it is alive.
*/
private disconnect(): void {
this.stopPing();
if (!this.isOpen) return;
this.isOpen = false;
if (this.onStateChange) this.onStateChange(false);
this.stopPing();
this.sendMessage({ type: 'close' });
if (this.popOutWindow) {
this.popOutWindow.close();
this.popOutWindow = null;
}
Logger.debug('[PopOut] Sent close message to viewer');
}
/**
@@ -308,12 +325,12 @@ export class PopOutManager {
}
/**
* Send a frame to the pop-out viewer.
* Send a frame to the pop-out viewer, unless it is hidden.
* Throttled to ~30fps for performance.
* @param canvas - The source canvas to send
*/
sendFrame(canvas: HTMLCanvasElement): void {
if (!this.isOpen || !this.channel) return;
if (!this.isOpen || this.viewerHidden || !this.channel) return;
// Throttle frame rate
const now = Date.now();
@@ -324,31 +341,25 @@ export class PopOutManager {
try {
// Convert canvas to data URL (efficient for small canvases)
const dataUrl = canvas.toDataURL('image/png');
this.sendMessage({
type: 'frame',
data: dataUrl,
timestamp: now
});
this.sendMessage({ type: 'frame', data: canvas.toDataURL('image/png') });
} catch (e) {
Logger.error('[PopOut] Failed to send frame:', e);
}
}
/**
* Send configuration to the pop-out viewer.
* Send the theme and the glass and Inspector state when they change.
*/
sendConfig(config: Partial<PopOutConfig>): void {
if (!this.channel) return;
if (config.theme) {
this.sentTheme = config.theme;
}
this.sendMessage({
type: 'config',
data: config
});
private syncConfig(): void {
// The viewer follows the palette's light or dark, which the frontend keeps as body.dark-theme
const config: PopOutConfig = {
theme: document.body.classList.contains('dark-theme') ? 'dark' : 'light',
...this.getStatus()
};
const json = JSON.stringify(config);
if (json === this.sentConfig) return;
this.sentConfig = json;
this.sendMessage({ type: 'config', data: config });
}
/**
@@ -573,40 +584,22 @@ export class PopOutManager {
}
/**
* Start pinging to check connection.
* Ping the viewer once a second while it is connected.
*/
private startPing(): void {
// Define ping function
const ping = () => {
if (this.channel) {
this.channel.postMessage({ type: 'ping', timestamp: Date.now() });
}
// Check for connection timeout
if (this.isOpen && Date.now() - this.lastPongTime > this.CONNECTION_TIMEOUT) {
// Count unanswered pings rather than time, so a throttled ComfyUI tab that pings late keeps its viewer
if (++this.pendingPings > this.PING_LIMIT) {
Logger.debug('[PopOut] Connection timeout, marking as closed');
this.isOpen = false;
this.stopPing(); // Stop pinging if timeout occurs (assume truly gone)
// Actually, if we want auto-reconnect, we should NOT stop pinging on timeout?
// But timeout implies "It was open, but stopped responding".
// If user closed it, we got 'close' message.
// If it crashed, we get timeout.
// If we stop pinging, we won't detect if it comes back?
// But usually we want to stop to save resources.
// Let's stick to original behavior for timeout: stop pinging.
// But for 'close' message: keep pinging (handled in handleMessage).
if (this.onStateChange) this.onStateChange(false);
this.disconnect();
return;
}
this.sendMessage({ type: 'ping' });
};
// Always send an immediate ping when startPing is called,
// to ensure responsiveness (e.g. when clicking Open button).
this.stopPing();
this.pendingPings = 0;
ping();
// If interval is already running, we don't need to start another one
if (this.pingInterval) return;
// Then ping every second
this.pingInterval = window.setInterval(ping, 1000);
}
@@ -625,7 +618,7 @@ export class PopOutManager {
*/
cleanup(): void {
this.close();
this.stopPing();
window.removeEventListener('pagehide', this.onPageHide);
if (this.channel) {
this.channel.close();
this.channel = null;