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;
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { PopOutManager, type PopOutStatus } from '../../src/magnify-glass/PopOutManager';
describe('Pop-out connection', () => {
let manager: PopOutManager;
let channels: { name: string; posted: any[] }[];
let status: PopOutStatus;
let connects: number;
const posted = () => channels[0].posted;
const pings = () => posted().filter(m => m.type === 'ping').length;
const receive = (message: unknown) => (manager as any).handleMessage(message);
beforeEach(() => {
vi.useFakeTimers();
sessionStorage.clear();
channels = [];
status = { glass: 'on', inspector: true };
connects = 0;
vi.stubGlobal('BroadcastChannel', class {
onmessage: ((e: MessageEvent) => void) | null = null;
posted: any[] = [];
constructor(public name: string) { channels.push(this); }
postMessage(message: unknown) { this.posted.push(message); }
close() { }
});
manager = new PopOutManager(() => status, () => connects++);
});
afterEach(() => {
manager.cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('uses a channel and viewer URL of its own session, which survives a reload', () => {
const session = sessionStorage.getItem('magnifyglass-popout-session')!;
expect(session).toBeTruthy();
expect(channels[0].name).toBe(`comfyui-magnifyglass-popout:${session}`);
expect(new URL((manager as any).viewerUrl).searchParams.get('session')).toBe(session);
const reloaded = new PopOutManager(() => status, () => { });
expect(channels[1].name).toBe(channels[0].name);
reloaded.cleanup();
});
it('pings only while a viewer is connected', () => {
vi.advanceTimersByTime(3000);
expect(pings()).toBe(0);
receive({ type: 'pong' });
expect(manager.isPopOutOpen()).toBe(true);
expect(connects).toBe(1);
vi.advanceTimersByTime(3000);
expect(pings()).toBe(4);
expect(connects).toBe(1);
});
it('times out on unanswered pings, not on a late one, and reconnects when the viewer announces itself', () => {
receive({ type: 'pong' });
// A throttled tab: each ping is answered, however late
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(1000);
receive({ type: 'pong' });
}
expect(manager.isPopOutOpen()).toBe(true);
vi.advanceTimersByTime(6000);
expect(manager.isPopOutOpen()).toBe(false);
const count = pings();
vi.advanceTimersByTime(3000);
expect(pings()).toBe(count);
receive({ type: 'pong' });
expect(manager.isPopOutOpen()).toBe(true);
});
it('a viewer that closed or reloaded reconnects; one closed from here waits for open()', () => {
vi.spyOn(window, 'open').mockReturnValue({ closed: false, focus() { }, close() { } } as unknown as Window);
receive({ type: 'pong' });
receive({ type: 'close' });
expect(manager.isPopOutOpen()).toBe(false);
receive({ type: 'pong' });
expect(manager.isPopOutOpen()).toBe(true);
manager.close();
expect(posted().at(-1)).toEqual({ type: 'close' });
receive({ type: 'pong' });
expect(manager.isPopOutOpen()).toBe(false);
manager.open();
receive({ type: 'pong' });
expect(manager.isPopOutOpen()).toBe(true);
});
it('focuses only a connected viewer; a disconnected window is opened again and never closed from here', () => {
const viewer = { closed: false, focus: vi.fn(), close: vi.fn() };
const open = vi.spyOn(window, 'open').mockReturnValue(viewer as unknown as Window);
manager.open();
// Another tab took the named window over before this one connected
manager.open();
expect(open).toHaveBeenCalledTimes(2);
manager.close();
expect(viewer.close).not.toHaveBeenCalled();
receive({ type: 'pong' });
manager.open();
expect(open).toHaveBeenCalledTimes(2);
expect(viewer.focus).toHaveBeenCalled();
manager.close();
expect(viewer.close).toHaveBeenCalled();
});
it('tells the viewer when ComfyUI goes away', () => {
window.dispatchEvent(new Event('pagehide'));
expect(posted()).toEqual([]);
receive({ type: 'pong' });
window.dispatchEvent(new Event('pagehide'));
expect(posted().at(-1)).toEqual({ type: 'close' });
});
it('sends the glass and Inspector state when they change', () => {
const configs = () => posted().filter(m => m.type === 'config').map(m => [m.data.glass, m.data.inspector]);
receive({ type: 'pong' });
receive({ type: 'pong' });
status = { glass: 'hidden', inspector: true };
receive({ type: 'pong' });
status = { glass: 'off', inspector: false };
receive({ type: 'pong' });
expect(configs()).toEqual([['on', true], ['hidden', true], ['off', false]]);
});
it('sends no frames while the viewer is hidden', () => {
const canvas = { toDataURL: () => 'data:image/png;base64,AA' } as unknown as HTMLCanvasElement;
const frames = () => posted().filter(m => m.type === 'frame').length;
receive({ type: 'pong' });
receive({ type: 'visibility', data: true });
manager.sendFrame(canvas);
expect(frames()).toBe(0);
receive({ type: 'visibility', data: false });
manager.sendFrame(canvas);
expect(frames()).toBe(1);
});
});
+1 -1
View File
@@ -13,7 +13,7 @@ describe('Pop-out theme', () => {
postMessage(message: unknown) { posted.push(message); }
close() { }
});
manager = new PopOutManager();
manager = new PopOutManager(() => ({ glass: 'on', inspector: true }), () => { });
});
afterEach(() => {
+67 -2
View File
@@ -8,7 +8,7 @@ const bodyHtml = html.slice(html.indexOf('<body>') + 6, html.indexOf('<script>')
const script = html.slice(html.indexOf('<script>') + 8, html.indexOf('</script>'));
describe('Pop-out viewer editing', () => {
let channel: { onmessage: ((e: { data: unknown }) => void) | null; postMessage: ReturnType<typeof vi.fn> };
let channel: { name: string; onmessage: ((e: { data: unknown }) => void) | null; postMessage: ReturnType<typeof vi.fn> };
const edits = () => channel.postMessage.mock.calls.map(([m]) => m).filter((m: any) => m.type === 'widget-edit').map((m: any) => m.data);
const sidebar = () => document.getElementById('sidebar-content')!;
const valueOf = (name: string) => [...sidebar().querySelectorAll('[data-kind="value"]')]
@@ -27,8 +27,9 @@ describe('Pop-out viewer editing', () => {
onmessage = null;
postMessage = vi.fn();
close() { }
constructor() { channel = this as any; }
constructor(public name: string) { channel = this as any; }
});
history.replaceState(null, '', '/popout-viewer.html?v=1&session=abc123');
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
vi.spyOn(window, 'close').mockImplementation(() => { });
document.body.innerHTML = bodyHtml;
@@ -36,6 +37,7 @@ describe('Pop-out viewer editing', () => {
});
afterEach(() => {
history.replaceState(null, '', '/');
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.unstubAllGlobals();
@@ -139,4 +141,67 @@ describe('Pop-out viewer editing', () => {
expect(posted).toEqual([{ type: 'zoom-node', data: id }, { type: 'request-nodes', data: 'title' }]);
expect((window as any).__pwned).toBeUndefined();
});
it('listens on the channel of the session in its URL, and announces itself until pinged', () => {
const pongs = () => channel.postMessage.mock.calls.filter(([m]) => m.type === 'pong').length;
expect(channel.name).toBe('comfyui-magnifyglass-popout:abc123');
expect(pongs()).toBe(1);
vi.advanceTimersByTime(2000);
expect(pongs()).toBe(3);
channel.onmessage!({ data: { type: 'ping' } });
expect(pongs()).toBe(4);
vi.advanceTimersByTime(2000);
expect(pongs()).toBe(4);
});
it('status follows pings and config, and never wipes the sidebar or an open editor', () => {
const label = () => document.querySelector('.status-label')!.textContent;
const ping = () => channel.onmessage!({ data: { type: 'ping' } });
const config = (glass: string) => channel.onmessage!({ data: { type: 'config', data: { theme: 'dark', glass, inspector: true } } });
ping();
config('off');
sendInfo([{ name: 'seed', type: 'number', value: 1 }]);
valueOf('seed').click();
const input = sidebar().querySelector('input[type="number"]');
expect(label()).toBe('Glass off');
expect(document.getElementById('viewer-canvas')!.classList.contains('dimmed')).toBe(true);
// No frames for a while is not a disconnect
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(1000);
ping();
}
expect(label()).toBe('Glass off');
config('hidden');
expect(label()).toBe('Glass hidden');
vi.advanceTimersByTime(4000);
expect(label()).toBe('ComfyUI tab inactive');
channel.onmessage!({ data: { type: 'close' } });
expect(label()).toBe('Disconnected from ComfyUI');
expect(window.close).not.toHaveBeenCalled();
expect(sidebar().querySelector('input[type="number"]')).toBe(input);
});
it('says when the Inspector is off in ComfyUI', () => {
sendInfo([{ name: 'seed', type: 'number', value: 1 }]);
channel.onmessage!({ data: { type: 'config', data: { theme: 'dark', glass: 'on', inspector: false } } });
expect(sidebar().textContent).toContain('The Inspector is off in ComfyUI');
channel.onmessage!({ data: { type: 'config', data: { theme: 'dark', glass: 'on', inspector: true } } });
expect(valueOf('seed')).toBeDefined();
});
it('Escape closes the node list, never the window', () => {
channel.onmessage!({ data: { type: 'info', data: { hoveredNode: { id: 5, title: 'Node', type: 'Node', widgets: [], inputs: [], outputs: [] } } } });
(sidebar().querySelector('[data-action="node-dropdown"]') as HTMLElement).click();
channel.onmessage!({ data: { type: 'nodes-list', data: { nodes: [{ id: 5, title: 'Node', type: 'Node' }], sortBy: 'title' } } });
expect(document.getElementById('node-dropdown')).not.toBeNull();
key(document.body, 'Escape');
expect(document.getElementById('node-dropdown')).toBeNull();
key(document.body, 'Escape');
expect(window.close).not.toHaveBeenCalled();
expect(channel.postMessage.mock.calls.map(([m]) => m.type)).not.toContain('close');
});
});
+9 -6
View File
@@ -30,11 +30,14 @@ class MagnifyGlass {
__publicField(this, "litegraphCanvas");
/** Last known mouse position for better initial positioning */
__publicField(this, "lastKnownMousePosition");
/** 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 */
__publicField(this, "animationLoopId");
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,
@@ -115,10 +118,9 @@ 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) {
this.state.isPreviewHidden = !visible;
@@ -188,7 +190,8 @@ class MagnifyGlass {
startAnimationLoop() {
if (this.animationLoopId !== null) return;
const animate = () => {
if (!this.state.active || this.state.isPreviewHidden || !this.renderer || !this.litegraphCanvas) {
const hidden = this.state.isPreviewHidden && !this.popOutManager.isPopOutOpen();
if (!this.state.active || hidden || !this.renderer || !this.litegraphCanvas) {
this.animationLoopId = null;
return;
}
+74 -64
View File
@@ -4,27 +4,41 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
import { BROADCAST_CHANNEL_NAME } from "../shared/constants.js";
import { Logger } from "../shared/logger.js";
import { WidgetSyncManager } from "../info-panel/widget-editors/WidgetSyncManager.js";
const SESSION_KEY = "magnifyglass-popout-session";
class PopOutManager {
constructor() {
constructor(getStatus, onConnect) {
__publicField(this, "channel", null);
__publicField(this, "isOpen", false);
__publicField(this, "popOutWindow", null);
__publicField(this, "onStateChange", null);
__publicField(this, "onNodeSelect", null);
__publicField(this, "onWidgetEdit", null);
__publicField(this, "lastPongTime", 0);
__publicField(this, "pingInterval", null);
// Pings since the last pong
__publicField(this, "pendingPings", 0);
// Closed from here: an announcing viewer waits until open() is called again
__publicField(this, "dismissed", false);
__publicField(this, "viewerHidden", false);
__publicField(this, "session");
__publicField(this, "viewerUrl");
// The theme last sent to the viewer, null until it connects
__publicField(this, "sentTheme", null);
// The config last sent to the viewer, null until it connects
__publicField(this, "sentConfig", null);
__publicField(this, "onPageHide", () => {
if (this.isOpen) this.sendMessage({ type: "close" });
});
/** Throttle frame sending to ~30fps */
__publicField(this, "lastFrameTime", 0);
__publicField(this, "FRAME_INTERVAL", 33);
// ~30fps
/** Connection timeout (ms) */
__publicField(this, "CONNECTION_TIMEOUT", 5e3);
/** Unanswered pings, one a second, before the viewer counts as gone */
__publicField(this, "PING_LIMIT", 5);
this.getStatus = getStatus;
this.onConnect = onConnect;
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);
}
/**
* Get the URL for the pop-out viewer page, which sits at the web root next to this module's folder.
@@ -36,19 +50,18 @@ class PopOutManager {
import.meta.url
);
url.searchParams.set("v", "1.15.0");
url.searchParams.set("session", this.session);
return url.href;
}
/**
* Initialize the BroadcastChannel.
* Open this session's BroadcastChannel; other ComfyUI tabs have their own.
*/
initChannel() {
try {
this.channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
this.channel = new BroadcastChannel(`${BROADCAST_CHANNEL_NAME}:${this.session}`);
this.channel.onmessage = (event) => {
this.handleMessage(event.data);
};
Logger.debug("[PopOut] BroadcastChannel initialized");
this.startPing();
} catch (e) {
Logger.error("[PopOut] Failed to create BroadcastChannel:", e);
}
@@ -59,26 +72,25 @@ class PopOutManager {
handleMessage(message) {
var _a, _b;
switch (message.type) {
case "pong": {
this.lastPongTime = Date.now();
if (!this.isOpen) {
case "pong":
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();
}
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;
Logger.debug("[PopOut] Viewer tab closed");
if (this.onStateChange) this.onStateChange(false);
this.disconnect();
break;
case "visibility":
this.viewerHidden = message.data;
break;
case "request-nodes":
Logger.debug("[PopOut] Received request-nodes:", message.data);
this.handleNodeListRequest(message.data);
break;
case "node-select":
@@ -158,32 +170,37 @@ class PopOutManager {
* Open the pop-out viewer in a popup window.
*/
open() {
if (this.popOutWindow && !this.popOutWindow.closed) {
this.popOutWindow.focus();
var _a;
this.dismissed = false;
if (this.isOpen) {
(_a = this.popOutWindow) == null ? void 0 : _a.focus();
return;
}
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;
}
this.startPing();
Logger.debug("[PopOut] Opening viewer tab...");
}
/**
* Close the pop-out viewer tab.
*/
close() {
if (!this.isOpen && !this.popOutWindow) return;
var _a;
if (!this.isOpen) return;
this.dismissed = true;
this.sendMessage({ type: "close" });
(_a = this.popOutWindow) == null ? void 0 : _a.close();
this.popOutWindow = null;
this.disconnect();
}
/**
* Mark the viewer as gone. It announces itself again while it is alive.
*/
disconnect() {
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");
}
/**
* Toggle the pop-out state.
@@ -202,40 +219,35 @@ class PopOutManager {
return this.isOpen;
}
/**
* 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) {
if (!this.isOpen || !this.channel) return;
if (!this.isOpen || this.viewerHidden || !this.channel) return;
const now = Date.now();
if (now - this.lastFrameTime < this.FRAME_INTERVAL) {
return;
}
this.lastFrameTime = now;
try {
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) {
if (!this.channel) return;
if (config.theme) {
this.sentTheme = config.theme;
}
this.sendMessage({
type: "config",
data: config
});
syncConfig() {
const config = {
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 });
}
/**
* Send inspector info to the pop-out viewer.
@@ -410,22 +422,20 @@ class PopOutManager {
}
}
/**
* Start pinging to check connection.
* Ping the viewer once a second while it is connected.
*/
startPing() {
const ping = () => {
if (this.channel) {
this.channel.postMessage({ type: "ping", timestamp: Date.now() });
}
if (this.isOpen && Date.now() - this.lastPongTime > this.CONNECTION_TIMEOUT) {
if (++this.pendingPings > this.PING_LIMIT) {
Logger.debug("[PopOut] Connection timeout, marking as closed");
this.isOpen = false;
this.stopPing();
if (this.onStateChange) this.onStateChange(false);
this.disconnect();
return;
}
this.sendMessage({ type: "ping" });
};
this.stopPing();
this.pendingPings = 0;
ping();
if (this.pingInterval) return;
this.pingInterval = window.setInterval(ping, 1e3);
}
/**
@@ -442,7 +452,7 @@ class PopOutManager {
*/
cleanup() {
this.close();
this.stopPing();
window.removeEventListener("pagehide", this.onPageHide);
if (this.channel) {
this.channel.close();
this.channel = null;
+55 -135
View File
@@ -208,6 +208,11 @@
box-shadow: var(--shadow-lg), 0 0 0 2px var(--accent-blue), 0 0 40px rgba(99, 102, 241, 0.15);
}
/* The last frame, while no new ones come */
#viewer-canvas.dimmed {
opacity: 0.4;
}
/* Resize handle */
.resize-handle {
position: absolute;
@@ -480,49 +485,6 @@
font-family: 'JetBrains Mono', monospace;
}
/* Keyboard hint */
.keyboard-hint {
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 6px;
font-size: 12px;
color: var(--text-tertiary);
}
.keyboard-hint kbd {
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border-default);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-secondary);
}
/* Checkbox */
.checkbox-container {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
margin-right: 12px;
user-select: none;
}
.checkbox-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.checkbox-container input {
cursor: pointer;
accent-color: var(--accent-blue);
width: 14px;
height: 14px;
}
.icon-button {
background: transparent;
border: 1px solid var(--border-subtle);
@@ -793,13 +755,6 @@
d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
</button>
<label class="checkbox-container" title="Prevent disconnection when idle">
<input type="checkbox" id="keep-alive-check">
<span class="checkbox-label">Keep Alive</span>
</label>
<div class="keyboard-hint">
<kbd>Esc</kbd> to close
</div>
<div class="status-indicator">
<span class="status-dot connecting"></span>
<span class="status-label">Connecting...</span>
@@ -855,6 +810,8 @@
<script>
const CHANNEL_NAME = 'comfyui-magnifyglass-popout';
// Each ComfyUI tab has its own channel, named by the session in this page's URL
const session = new URLSearchParams(location.search).get('session');
// DOM Elements
const canvas = document.getElementById('viewer-canvas');
@@ -864,30 +821,14 @@
const placeholder = document.querySelector('.canvas-placeholder');
const sidebarContent = document.getElementById('sidebar-content');
const emptyState = document.getElementById('empty-state');
const keepAliveCheck = document.getElementById('keep-alive-check');
// State
let isConnected = false;
let lastFrameTime = 0;
let keepAlive = false;
// State: 'connecting', 'connected', 'inactive' (no ping for a while) or 'closed' (ComfyUI closed or reloaded, or closed this viewer)
let connection = 'connecting';
let lastPingTime = 0;
let config = { glass: 'on', inspector: true };
const frameImage = new Image();
// Initialize BroadcastChannel
const channel = new BroadcastChannel(CHANNEL_NAME);
// Access Keep-Alive setting
const savedKeepAlive = localStorage.getItem('magnifyglass-viewer-keepalive');
if (savedKeepAlive === 'true') {
keepAlive = true;
keepAliveCheck.checked = true;
}
keepAliveCheck.addEventListener('change', (e) => {
keepAlive = e.target.checked;
localStorage.setItem('magnifyglass-viewer-keepalive', keepAlive);
// If reconnecting/keeping alive, we might want to manually mark connected if we have a frame?
// But really we just prevent disconnection.
});
const channel = new BroadcastChannel(`${CHANNEL_NAME}:${session}`);
// Fullscreen Logic
const fullscreenBtn = document.getElementById('fullscreen-btn');
@@ -930,11 +871,12 @@
handleInfo(message.data);
break;
case 'ping':
channel.postMessage({ type: 'pong', timestamp: Date.now() });
markConnected();
lastPingTime = Date.now();
channel.postMessage({ type: 'pong' });
setConnection('connected');
break;
case 'close':
markDisconnected();
setConnection('closed');
break;
case 'nodes-list':
handleNodesList(message.data);
@@ -958,29 +900,15 @@
};
frameImage.src = dataUrl;
lastFrameTime = Date.now();
markConnected();
placeholder.classList.add('hidden');
}
function handleConfig(config) {
if (!config) return;
if (config.borderColor) {
canvas.style.boxShadow = `var(--shadow-lg), 0 0 0 2px ${config.borderColor}`;
}
if (config.glassShape === 'Circle') {
canvas.style.borderRadius = '50%';
} else if (config.glassShape === 'Rounded Square') {
canvas.style.borderRadius = '20%';
} else {
canvas.style.borderRadius = 'var(--radius-lg)';
}
if (config.theme) {
document.documentElement.setAttribute('data-theme', config.theme);
}
function handleConfig(data) {
document.documentElement.setAttribute('data-theme', data.theme);
const inspectorChanged = data.inspector !== config.inspector;
config = data;
renderStatus();
if (inspectorChanged) handleInfo(currentInfo);
}
// The latest info, and the node the sidebar was last rendered from
@@ -991,7 +919,7 @@
currentInfo = info;
// Re-rendering would drop an open editor; closing it renders the latest info
if (sidebarContent.querySelector('.widget-editor-inline')) return;
if (!info) {
if (!info || !config.inspector) {
showEmptyState();
return;
}
@@ -1010,8 +938,7 @@
nodeId: nodeId,
widgetName: widgetName,
value: value
},
timestamp: Date.now()
}
});
}
@@ -1025,8 +952,7 @@
data: {
nodeId: nodeId,
widgetName: widgetName
},
timestamp: Date.now()
}
});
}
@@ -1175,8 +1101,6 @@
}
editor.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Escape on the page closes the window
e.stopPropagation();
finish(false);
} else if (e.key === 'Enter' && (editor.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
e.preventDefault();
@@ -1688,8 +1612,10 @@
}
function showEmptyState() {
const empty = emptyState.cloneNode(true);
if (!config.inspector) empty.querySelector('.empty-state-text').textContent = 'The Inspector is off in ComfyUI';
sidebarContent.innerHTML = '';
sidebarContent.appendChild(emptyState.cloneNode(true));
sidebarContent.appendChild(empty);
}
function hideEmptyState() {
@@ -1810,51 +1736,45 @@
}
}
function markConnected() {
if (!isConnected) {
isConnected = true;
statusDot.classList.remove('connecting');
statusDot.classList.add('connected');
statusLabel.textContent = 'Connected';
placeholder.classList.add('hidden');
}
const STATUS_LABELS = { connecting: 'Connecting...', inactive: 'ComfyUI tab inactive', closed: 'Disconnected from ComfyUI' };
const GLASS_LABELS = { on: 'Connected', hidden: 'Glass hidden', off: 'Glass off' };
function setConnection(state) {
if (state === connection) return;
connection = state;
renderStatus();
// The ComfyUI tab skips frames while this page is hidden
if (state === 'connected') channel.postMessage({ type: 'visibility', data: document.hidden });
}
function markDisconnected() {
isConnected = false;
statusDot.classList.remove('connected');
statusDot.classList.add('connecting');
statusLabel.textContent = 'Disconnected';
placeholder.classList.remove('hidden');
canvas.classList.remove('active');
showEmptyState();
// Status never touches the sidebar, so an open editor and the last info stay
function renderStatus() {
const live = connection === 'connected';
statusDot.classList.toggle('connected', live && config.glass !== 'off');
statusDot.classList.toggle('connecting', connection === 'connecting' || connection === 'inactive');
statusLabel.textContent = live ? GLASS_LABELS[config.glass] : STATUS_LABELS[connection];
canvas.classList.toggle('dimmed', !live || config.glass === 'off');
}
// Check connection status
document.addEventListener('visibilitychange', () => {
if (connection === 'connected') channel.postMessage({ type: 'visibility', data: document.hidden });
});
// ComfyUI pings only while it counts this page as connected; until then announce once a second
setInterval(() => {
if (!keepAlive && isConnected && Date.now() - lastFrameTime > 3000) {
markDisconnected();
}
if (connection === 'connected' && Date.now() - lastPingTime > 3000) setConnection('inactive');
if (connection !== 'connected') channel.postMessage({ type: 'pong' });
}, 1000);
channel.postMessage({ type: 'pong' });
// Initial ping
channel.postMessage({ type: 'pong', timestamp: Date.now() });
// Cleanup
window.addEventListener('beforeunload', () => {
channel.postMessage({ type: 'close' });
channel.close();
});
// Escape to close
// Escape closes the node list; an editor handles its own
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (document.fullscreenElement) {
return;
}
channel.postMessage({ type: 'close' });
window.close();
}
if (e.key === 'Escape') hideDropdown();
});
// Fit Mode Logic