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:
co-authored by
Claude Opus 5.5
parent
305a163b10
commit
76bb34da1d
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user