From 76bb34da1d988e31f5bd4537b1df7cf63a00bbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86motion=20Studio?= <163354043+AEmotionStudio@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:43:59 -0800 Subject: [PATCH] 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 --- src/magnify-glass/MagnifyGlass.ts | 16 +- src/magnify-glass/PopOutManager.ts | 225 ++++++++++++++-------------- tests/unit/PopOutConnection.test.ts | 147 ++++++++++++++++++ tests/unit/PopOutTheme.test.ts | 2 +- tests/unit/PopOutViewer.test.ts | 69 ++++++++- web/magnify-glass/MagnifyGlass.js | 15 +- web/magnify-glass/PopOutManager.js | 138 +++++++++-------- web/popout-viewer.html | 190 +++++++---------------- 8 files changed, 472 insertions(+), 330 deletions(-) create mode 100644 tests/unit/PopOutConnection.test.ts diff --git a/src/magnify-glass/MagnifyGlass.ts b/src/magnify-glass/MagnifyGlass.ts index e5b9cb1..9665be0 100644 --- a/src/magnify-glass/MagnifyGlass.ts +++ b/src/magnify-glass/MagnifyGlass.ts @@ -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; } diff --git a/src/magnify-glass/PopOutManager.ts b/src/magnify-glass/PopOutManager.ts index a25442b..b5e55b2 100644 --- a/src/magnify-glass/PopOutManager.ts +++ b/src/magnify-glass/PopOutManager.ts @@ -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 | 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) => { 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): 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; diff --git a/tests/unit/PopOutConnection.test.ts b/tests/unit/PopOutConnection.test.ts new file mode 100644 index 0000000..98d12e2 --- /dev/null +++ b/tests/unit/PopOutConnection.test.ts @@ -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); + }); +}); diff --git a/tests/unit/PopOutTheme.test.ts b/tests/unit/PopOutTheme.test.ts index a5d47b4..18768c8 100644 --- a/tests/unit/PopOutTheme.test.ts +++ b/tests/unit/PopOutTheme.test.ts @@ -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(() => { diff --git a/tests/unit/PopOutViewer.test.ts b/tests/unit/PopOutViewer.test.ts index ace2c65..492f011 100644 --- a/tests/unit/PopOutViewer.test.ts +++ b/tests/unit/PopOutViewer.test.ts @@ -8,7 +8,7 @@ const bodyHtml = html.slice(html.indexOf('') + 6, html.indexOf('')); describe('Pop-out viewer editing', () => { - let channel: { onmessage: ((e: { data: unknown }) => void) | null; postMessage: ReturnType }; + let channel: { name: string; onmessage: ((e: { data: unknown }) => void) | null; postMessage: ReturnType }; 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'); + }); }); diff --git a/web/magnify-glass/MagnifyGlass.js b/web/magnify-glass/MagnifyGlass.js index 80b82b0..9ff9c11 100644 --- a/web/magnify-glass/MagnifyGlass.js +++ b/web/magnify-glass/MagnifyGlass.js @@ -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; } diff --git a/web/magnify-glass/PopOutManager.js b/web/magnify-glass/PopOutManager.js index d62e035..2fa0289 100644 --- a/web/magnify-glass/PopOutManager.js +++ b/web/magnify-glass/PopOutManager.js @@ -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; diff --git a/web/popout-viewer.html b/web/popout-viewer.html index 5ea1396..fc1267e 100644 --- a/web/popout-viewer.html +++ b/web/popout-viewer.html @@ -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" /> - -
- Esc to close -
Connecting... @@ -855,6 +810,8 @@