Add zoom, view modes and a resizable Inspector to the pop-out viewer

The pop-out can set the glass zoom, switch between Split, Glass and Info,
and resize the Inspector by dragging or with the arrow keys; the layout is kept per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-11-04 09:40:04 -08:00
co-authored by Claude Opus 5.5
parent 1aad893f6f
commit a7ea681e7a
8 changed files with 418 additions and 29 deletions
+4 -3
View File
@@ -5,7 +5,7 @@
*/
import type { ComfyApp } from '../types/comfyui';
import { findLiteGraphCanvas } from '../shared/utils';
import { findLiteGraphCanvas, setSettingValue } from '../shared/utils';
import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from '../shared/constants';
import { registerGlassSettings, registerAccessibilitySettings } from '../shared/settings';
import { ConfigManager } from './ConfigManager';
@@ -51,8 +51,9 @@ export class MagnifyGlass {
this.popOutManager = new PopOutManager(() => ({
glass: !this.state.active ? 'off' : this.state.isPreviewHidden ? 'hidden' : 'on',
inspector: !!window.infoPanelManager,
reduceMotion: this.config.reduceMotion
}), () => this.updateMagnifiedView());
reduceMotion: this.config.reduceMotion,
zoom: Math.round(this.config.zoomFactor * 100)
}), () => this.updateMagnifiedView(), (zoom) => setSettingValue('🔍MagnifyGlass.ZoomFactor', zoom));
this.ui = new UiManager(
this.config,
this.state,
+13 -1
View File
@@ -34,6 +34,8 @@ export interface PopOutStatus {
glass: 'on' | 'hidden' | 'off';
inspector: boolean;
reduceMotion: boolean;
/** The glass zoom, in percent */
zoom: number;
}
/**
@@ -106,6 +108,7 @@ type PopOutMessage =
| { type: 'pong' }
| { type: 'close' }
| { type: 'visibility'; data: boolean }
| { type: 'zoom'; data: number }
| { type: 'frame'; data: Blob }
| { type: 'config'; data: PopOutConfig }
| { type: 'info'; data: PopOutInfo }
@@ -159,7 +162,11 @@ export class PopOutManager {
/** Unanswered pings, one a second, before the viewer counts as gone */
private readonly PING_LIMIT = 5;
constructor(private readonly getStatus: () => PopOutStatus, private readonly onConnect: () => void) {
constructor(
private readonly getStatus: () => PopOutStatus,
private readonly onConnect: () => void,
private readonly onZoom: (zoom: number) => 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);
@@ -217,6 +224,11 @@ export class PopOutManager {
case 'visibility':
this.viewerHidden = message.data;
break;
case 'zoom':
this.onZoom(message.data);
// The setting applies at once; the viewer need not wait for the next pong to show it
this.syncConfig();
break;
case 'search-nodes':
if (this.onSearchNodes) {
this.sendMessage({ type: 'nodes-list', data: { query: message.data, nodes: this.onSearchNodes(message.data) } });
+17 -5
View File
@@ -6,6 +6,7 @@ describe('Pop-out connection', () => {
let channels: { name: string; posted: any[] }[];
let status: PopOutStatus;
let connects: number;
let zooms: number[];
const posted = () => channels[0].posted;
const pings = () => posted().filter(m => m.type === 'ping').length;
const receive = (message: unknown) => (manager as any).handleMessage(message);
@@ -14,8 +15,9 @@ describe('Pop-out connection', () => {
vi.useFakeTimers();
sessionStorage.clear();
channels = [];
status = { glass: 'on', inspector: true, reduceMotion: false };
status = { glass: 'on', inspector: true, reduceMotion: false, zoom: 300 };
connects = 0;
zooms = [];
vi.stubGlobal('BroadcastChannel', class {
onmessage: ((e: MessageEvent) => void) | null = null;
posted: any[] = [];
@@ -23,7 +25,7 @@ describe('Pop-out connection', () => {
postMessage(message: unknown) { this.posted.push(message); }
close() { }
});
manager = new PopOutManager(() => status, () => connects++);
manager = new PopOutManager(() => status, () => connects++, (zoom) => zooms.push(zoom));
});
afterEach(() => {
@@ -39,7 +41,7 @@ describe('Pop-out connection', () => {
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, () => { });
const reloaded = new PopOutManager(() => status, () => { }, () => { });
expect(channels[1].name).toBe(channels[0].name);
reloaded.cleanup();
});
@@ -124,13 +126,23 @@ describe('Pop-out connection', () => {
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, reduceMotion: false };
status = { glass: 'hidden', inspector: true, reduceMotion: false, zoom: 300 };
receive({ type: 'pong' });
status = { glass: 'off', inspector: false, reduceMotion: false };
status = { glass: 'off', inspector: false, reduceMotion: false, zoom: 300 };
receive({ type: 'pong' });
expect(configs()).toEqual([['on', true], ['hidden', true], ['off', false]]);
});
it('hands a zoom to the glass and sends the config it made at once', () => {
const configZooms = () => posted().filter(m => m.type === 'config').map(m => m.data.zoom);
receive({ type: 'pong' });
status = { ...status, zoom: 350 };
receive({ type: 'zoom', data: 350 });
expect(zooms).toEqual([350]);
expect(configZooms()).toEqual([300, 350]);
});
it('sends frames as Blobs, one encode at a time, and none while the viewer is hidden', () => {
const blob = new Blob(['png'], { type: 'image/png' });
let encode: BlobCallback | null = null;
+1 -1
View File
@@ -13,7 +13,7 @@ describe('Pop-out palette', () => {
postMessage(message: unknown) { posted.push(message); }
close() { }
});
manager = new PopOutManager(() => ({ glass: 'on', inspector: true, reduceMotion: false }), () => { });
manager = new PopOutManager(() => ({ glass: 'on', inspector: true, reduceMotion: false, zoom: 300 }), () => { }, () => { });
});
afterEach(() => {
+111 -5
View File
@@ -385,7 +385,7 @@ describe('Pop-out viewer editing', () => {
expect(channel.postMessage.mock.calls.map(([m]) => m.type)).not.toContain('close');
// Nothing to navigate while the Inspector is off
channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, glass: 'on', inspector: false } } });
channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, zoom: 300, glass: 'on', inspector: false } } });
key(document.body, 'ArrowRight');
key(document.body, 'k', { ctrlKey: true });
expect(posted('panel-action')).toHaveLength(3);
@@ -414,7 +414,7 @@ describe('Pop-out viewer editing', () => {
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: { palette, reduceMotion: false, glass, inspector: true } } });
const config = (glass: string) => channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, zoom: 300, glass, inspector: true } } });
ping();
config('off');
sendInfo([row('seed', 'INT', 1)]);
@@ -442,16 +442,16 @@ describe('Pop-out viewer editing', () => {
it('says when the Inspector is off in ComfyUI', () => {
sendInfo([row('seed', 'INT', 1)]);
channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, glass: 'on', inspector: false } } });
channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, zoom: 300, glass: 'on', inspector: false } } });
expect(sidebar().textContent).toContain('The Inspector is off in ComfyUI');
channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, glass: 'on', inspector: true } } });
channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, zoom: 300, glass: 'on', inspector: true } } });
expect(valueOf('seed')).toBeDefined();
});
it('applies the palette ComfyUI sends, drops an override that went away, and follows Reduce Motion', () => {
const root = document.documentElement;
const config = (properties: Record<string, string>, scheme: string, reduceMotion: boolean) =>
channel.onmessage!({ data: { type: 'config', data: { glass: 'on', inspector: true, reduceMotion, palette: { ...palette, scheme, properties } } } });
channel.onmessage!({ data: { type: 'config', data: { glass: 'on', inspector: true, reduceMotion, zoom: 300, palette: { ...palette, scheme, properties } } } });
config({ '--fg-color': '#222222', '--mg-panel-accent': '#ff8800' }, 'light', true);
expect(root.style.getPropertyValue('--fg-color')).toBe('#222222');
@@ -468,6 +468,112 @@ describe('Pop-out viewer editing', () => {
expect(root.classList.contains('reduce-motion')).toBe(false);
});
it('zoom buttons and the wheel write the zoom once they rest for 250 ms, within 100-1000%', () => {
const zoomIn = document.getElementById('zoom-in') as HTMLButtonElement;
const zoomOut = document.getElementById('zoom-out') as HTMLButtonElement;
const shown = () => document.querySelector('.zoom-value')!.textContent;
const config = (zoom: number) => receive('config', { palette, reduceMotion: false, zoom, glass: 'on', inspector: true });
const wheel = (deltaY: number) => document.querySelector('.canvas-section')!.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true }));
expect(zoomIn.disabled).toBe(true);
receive('ping');
config(300);
expect(shown()).toBe('300%');
zoomIn.click();
zoomIn.click();
wheel(-100);
expect(shown()).toBe('375%');
vi.advanceTimersByTime(249);
// ComfyUI's older zoom does not replace one still waiting to be written
config(300);
expect(shown()).toBe('375%');
expect(posted('zoom')).toEqual([]);
vi.advanceTimersByTime(1);
expect(posted('zoom')).toEqual([375]);
// A touchpad's small deltas add up to a step
wheel(40);
wheel(40);
expect(shown()).toBe('375%');
wheel(40);
expect(shown()).toBe('350%');
zoomOut.click();
vi.advanceTimersByTime(250);
expect(posted('zoom')).toEqual([375, 325]);
config(1000);
expect(zoomIn.disabled).toBe(true);
wheel(-100);
vi.advanceTimersByTime(250);
expect(posted('zoom')).toHaveLength(2);
config(110);
zoomOut.click();
vi.advanceTimersByTime(250);
expect(posted('zoom')).toEqual([375, 325, 100]);
});
it('keeps the view mode, the Inspector width and the collapsed sections across reloads', () => {
const main = document.querySelector('.main-container') as HTMLElement;
const viewButton = (view: string) => document.querySelector(`[data-view="${view}"]`) as HTMLButtonElement;
const divider = document.querySelector('.inspector-divider') as HTMLElement;
vi.spyOn(main, 'getBoundingClientRect').mockReturnValue({ right: 1000, width: 1000 } as DOMRect);
divider.setPointerCapture = vi.fn();
receive('ping');
sendInfo([row('seed', 'INT', 1)]);
viewButton('info').click();
expect(main.dataset.view).toBe('info');
expect(viewButton('info').getAttribute('aria-pressed')).toBe('true');
expect(viewButton('split').getAttribute('aria-pressed')).toBe('false');
expect(main.hasAttribute('aria-pressed')).toBe(false);
// Info shows no frame, so ComfyUI need not send any
expect(posted('visibility').at(-1)).toBe(true);
viewButton('glass').click();
expect(posted('visibility').at(-1)).toBe(false);
divider.dispatchEvent(new PointerEvent('pointerdown', { button: 0, pointerId: 1 }));
divider.dispatchEvent(new PointerEvent('pointermove', { clientX: 600 }));
expect(main.style.getPropertyValue('--inspector-w')).toBe('400px');
// The frame keeps its minimum, the Inspector its own
divider.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 }));
expect(main.style.getPropertyValue('--inspector-w')).toBe('808px');
divider.dispatchEvent(new PointerEvent('pointermove', { clientX: 990 }));
expect(main.style.getPropertyValue('--inspector-w')).toBe('240px');
divider.dispatchEvent(new PointerEvent('pointermove', { clientX: 650 }));
divider.dispatchEvent(new Event('lostpointercapture'));
const visibilityPosts = posted('visibility').length;
(sidebar().querySelector('.section-header') as HTMLElement).click();
expect(posted('visibility').length).toBe(visibilityPosts);
document.body.innerHTML = bodyHtml;
new Function(script)();
sendInfo([row('seed', 'INT', 1)]);
const reloaded = document.querySelector('.main-container') as HTMLElement;
expect(reloaded.dataset.view).toBe('glass');
expect(document.querySelector('[data-view="glass"]')!.getAttribute('aria-pressed')).toBe('true');
expect(reloaded.style.getPropertyValue('--inspector-w')).toBe('350px');
expect(sidebar().querySelector('.info-section.collapsed')).not.toBeNull();
// Double-click resets the width
document.querySelector('.inspector-divider')!.dispatchEvent(new Event('dblclick'));
expect(reloaded.style.getPropertyValue('--inspector-w')).toBe('');
expect(JSON.parse(localStorage.getItem('magnifyglass-viewer-layout')!).inspectorWidth).toBeNull();
});
it('the divider resizes with the arrow keys, not Prev/Next', () => {
const main = document.querySelector('.main-container') as HTMLElement;
const divider = document.querySelector('.inspector-divider') as HTMLElement;
vi.spyOn(main, 'getBoundingClientRect').mockReturnValue({ right: 1000, width: 1000 } as DOMRect);
vi.spyOn(document.getElementById('inspector')!, 'getBoundingClientRect').mockReturnValue({ width: 320 } as DOMRect);
sendInfo([]);
key(divider, 'ArrowLeft');
expect(main.style.getPropertyValue('--inspector-w')).toBe('336px');
key(divider, 'ArrowRight');
expect(main.style.getPropertyValue('--inspector-w')).toBe('304px');
expect(posted('panel-action')).toEqual([]);
});
it('draws a Blob frame at its own size and closes its bitmap', async () => {
const bitmap = { width: 400, height: 400, close: vi.fn() };
const decode = vi.fn().mockResolvedValue(bitmap);
+4 -3
View File
@@ -1,7 +1,7 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { findLiteGraphCanvas } from "../shared/utils.js";
import { setSettingValue, findLiteGraphCanvas } from "../shared/utils.js";
import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from "../shared/constants.js";
import "../shared/settings/defaults.js";
import { registerGlassSettings } from "../shared/settings/glassSettings.js";
@@ -37,8 +37,9 @@ class MagnifyGlass {
this.popOutManager = new PopOutManager(() => ({
glass: !this.state.active ? "off" : this.state.isPreviewHidden ? "hidden" : "on",
inspector: !!window.infoPanelManager,
reduceMotion: this.config.reduceMotion
}), () => this.updateMagnifiedView());
reduceMotion: this.config.reduceMotion,
zoom: Math.round(this.config.zoomFactor * 100)
}), () => this.updateMagnifiedView(), (zoom) => setSettingValue("🔍MagnifyGlass.ZoomFactor", zoom));
this.ui = new UiManager(
this.config,
this.state,
+6 -1
View File
@@ -22,7 +22,7 @@ const PALETTE_PROPERTIES = [
];
const SESSION_KEY = "magnifyglass-popout-session";
class PopOutManager {
constructor(getStatus, onConnect) {
constructor(getStatus, onConnect, onZoom) {
__publicField(this, "channel", null);
__publicField(this, "isOpen", false);
__publicField(this, "popOutWindow", null);
@@ -57,6 +57,7 @@ class PopOutManager {
__publicField(this, "PING_LIMIT", 5);
this.getStatus = getStatus;
this.onConnect = onConnect;
this.onZoom = onZoom;
this.session = sessionStorage.getItem(SESSION_KEY) ?? Math.random().toString(36).slice(2);
sessionStorage.setItem(SESSION_KEY, this.session);
this.viewerUrl = this.getViewerUrl();
@@ -113,6 +114,10 @@ class PopOutManager {
case "visibility":
this.viewerHidden = message.data;
break;
case "zoom":
this.onZoom(message.data);
this.syncConfig();
break;
case "search-nodes":
if (this.onSearchNodes) {
this.sendMessage({ type: "nodes-list", data: { query: message.data, nodes: this.onSearchNodes(message.data) } });
+262 -10
View File
@@ -81,11 +81,12 @@
/* Header */
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
gap: 0.25rem 0.75rem;
min-height: 2.75rem;
padding: 0 0.5rem 0 0.75rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
border-bottom: 1px solid var(--mg-stroke);
}
@@ -106,7 +107,9 @@
.header-right {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
}
@@ -172,10 +175,53 @@
cursor: default;
}
/* The glass zoom, written to ComfyUI's Zoom Level setting */
.zoom-control {
display: flex;
align-items: center;
}
.zoom-value {
min-width: 3rem;
color: var(--mg-muted);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
text-align: center;
}
.view-modes {
display: flex;
padding: 0.125rem;
border: 1px solid var(--mg-stroke);
border-radius: 0.5rem;
}
.view-modes button {
min-height: 1.625rem;
padding: 0 0.625rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: var(--mg-muted);
font-size: 0.75rem;
}
.view-modes button:hover {
background: var(--mg-control-hover);
color: var(--mg-fg);
}
.view-modes button[aria-pressed="true"] {
background: var(--mg-control);
color: var(--mg-fg);
font-weight: 600;
}
/* Frame and Inspector side by side; the Inspector stacks under the frame in narrow windows */
/* The divider sets --inspector-w; the frame keeps at least 12rem */
.main-container {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--inspector-w, 20rem);
grid-template-columns: minmax(0, 1fr) min(var(--inspector-w, 20rem), calc(100% - 12rem));
min-height: 0;
}
@@ -309,6 +355,7 @@
/* Inspector */
.inspector-sidebar {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
@@ -318,6 +365,31 @@
color: var(--mg-fg);
}
.inspector-divider {
position: absolute;
top: 0;
bottom: 0;
left: -0.3125rem;
z-index: 1;
width: 0.5rem;
cursor: col-resize;
touch-action: none;
}
.inspector-divider:focus-visible {
outline: none;
}
.inspector-divider:is(:hover, :focus-visible, .dragging)::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: calc(50% - 1px);
width: 2px;
background: var(--mg-accent);
}
/* The node's identity, then the navigation row and the breadcrumb, as in the Inspector */
.node-header {
border-bottom: 1px solid var(--mg-stroke);
@@ -933,11 +1005,39 @@
border-left: 0;
}
.inspector-divider,
.logo-text {
display: none;
}
.canvas-wrapper {
inset: 0.75rem;
}
}
/* Glass or Info alone */
.main-container:not([data-view="split"]) {
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
}
.main-container[data-view="glass"] .inspector-sidebar,
.main-container[data-view="info"] .canvas-section {
display: none;
}
.main-container[data-view="info"] .inspector-sidebar {
border: 0;
}
.main-container:not([data-view="split"]) .inspector-divider {
display: none;
}
/* Fullscreen is for the frame, whatever the view */
:root:fullscreen .main-container .canvas-section {
display: block;
}
/* The system setting, and ComfyUI's Reduce Motion setting */
@media (prefers-reduced-motion: reduce) {
*,
@@ -970,6 +1070,20 @@
<span class="status-dot connecting"></span>
<span class="status-label">Connecting...</span>
</div>
<div class="zoom-control" role="group" aria-label="Glass zoom">
<button type="button" id="zoom-out" class="icon-button" title="Zoom out" aria-label="Zoom out" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<span class="zoom-value" title="Glass zoom; scroll over the frame to change it">–</span>
<button type="button" id="zoom-in" class="icon-button" title="Zoom in" aria-label="Zoom in" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="view-modes" role="group" aria-label="View">
<button type="button" data-view="split" title="Frame and Inspector" aria-pressed="true">Split</button>
<button type="button" data-view="glass" title="Frame only" aria-pressed="false">Glass</button>
<button type="button" data-view="info" title="Inspector only" aria-pressed="false">Info</button>
</div>
<button id="fit-btn" class="icon-button" title="Fit to window" aria-label="Fit to window" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -986,7 +1100,7 @@
</div>
</header>
<main class="main-container">
<main class="main-container" data-view="split">
<section class="canvas-section">
<div class="canvas-wrapper">
<div class="canvas-inner">
@@ -1005,6 +1119,8 @@
</section>
<aside class="inspector-sidebar" id="inspector">
<div class="inspector-divider" role="separator" aria-orientation="vertical" aria-label="Inspector width" aria-valuemin="240" tabindex="0"
title="Drag to resize, double-click to reset"></div>
<div class="node-header">
<button type="button" class="node-identity" data-action="copy-title" title="Copy title and id" aria-label="Copy title and id" disabled>
<span class="node-name is-empty">Inspector</span>
@@ -1078,6 +1194,26 @@
const pickerFilter = picker.querySelector('.node-picker-filter');
const pickerList = picker.querySelector('.node-picker-list');
const pickerEmpty = picker.querySelector('.node-picker-empty');
const mainContainer = document.querySelector('.main-container');
const canvasSection = document.querySelector('.canvas-section');
const divider = inspector.querySelector('.inspector-divider');
const zoomValue = document.querySelector('.zoom-value');
const zoomOut = document.getElementById('zoom-out');
const zoomIn = document.getElementById('zoom-in');
const viewButtons = document.querySelectorAll('.view-modes [data-view]');
// The view mode, the Inspector's width and the collapsed sections, kept per browser
const LAYOUT_KEY = 'magnifyglass-viewer-layout';
let layout = { view: 'split', inspectorWidth: null, collapsed: [] };
try {
layout = { ...layout, ...JSON.parse(localStorage.getItem(LAYOUT_KEY) || '{}') };
} catch {
// An unreadable saved layout: start from the defaults
}
function saveLayout() {
localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...layout, collapsed: [...collapsedSections] }));
}
// State: 'connecting', 'connected', 'inactive' (no ping for a while) or 'closed' (ComfyUI closed or reloaded, or closed this viewer)
let connection = 'connecting';
@@ -1109,6 +1245,8 @@
fullscreenBtn.innerHTML = full ? exitFullscreenIcon : enterFullscreenIcon;
fullscreenBtn.title = full ? 'Exit fullscreen' : 'Enter fullscreen';
fullscreenBtn.setAttribute('aria-label', fullscreenBtn.title);
// Info shows the frame in fullscreen
postVisibility();
});
channel.onmessage = (event) => {
@@ -1163,6 +1301,8 @@
applyPalette(data.palette);
document.documentElement.classList.toggle('reduce-motion', data.reduceMotion);
config = data;
// A zoom still waiting to be written is newer than ComfyUI's
if (zoomTimer === null) zoom = data.zoom;
renderStatus();
// Renders only when the Inspector was turned on or off
handleInfo(currentInfo);
@@ -1188,7 +1328,7 @@
// The texts of the rendered copy buttons, the long values shown in full and the collapsed sections
let copyTexts = [];
const expandedValues = new Set();
const collapsedSections = new Set();
const collapsedSections = new Set(layout.collapsed);
function handleInfo(info) {
currentInfo = info;
@@ -1514,6 +1654,7 @@
} else {
collapsedSections.delete(section.dataset.sectionId);
}
saveLayout();
}
function toggleExpanded(button) {
@@ -1747,8 +1888,13 @@
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 });
if (state === 'connected') postVisibility();
}
// The ComfyUI tab skips frames while this page is hidden or shows only the Inspector
function postVisibility() {
if (connection !== 'connected') return;
channel.postMessage({ type: 'visibility', data: document.hidden || (layout.view === 'info' && !document.fullscreenElement) });
}
// Status never touches the sidebar, so an open editor and the last info stay
@@ -1758,11 +1904,10 @@
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');
renderZoom();
}
document.addEventListener('visibilitychange', () => {
if (connection === 'connected') channel.postMessage({ type: 'visibility', data: document.hidden });
});
document.addEventListener('visibilitychange', postVisibility);
// ComfyUI pings only while it counts this page as connected; until then announce once a second
setInterval(() => {
@@ -1844,6 +1989,113 @@
canvas.style.removeProperty('--canvas-size');
localStorage.removeItem('magnifyglass-viewer-size');
});
// Split shows the frame and the Inspector, Glass and Info one of them
const VIEWS = ['split', 'glass', 'info'];
function setView(view) {
layout.view = VIEWS.includes(view) ? view : 'split';
mainContainer.dataset.view = layout.view;
for (const button of viewButtons) button.setAttribute('aria-pressed', String(button.dataset.view === layout.view));
if (layout.view === 'split') divider.setAttribute('aria-valuenow', String(Math.round(inspector.getBoundingClientRect().width)));
postVisibility();
}
if (layout.inspectorWidth > 0) mainContainer.style.setProperty('--inspector-w', `${layout.inspectorWidth}px`);
setView(layout.view);
for (const button of viewButtons) {
button.addEventListener('click', () => {
setView(button.dataset.view);
saveLayout();
});
}
// The divider sets the Inspector's width, leaving the frame at least 12rem; double-click resets it
let dividerDrag = false;
function setInspectorWidth(width) {
layout.inspectorWidth = width === null ? null : Math.round(Math.max(240, Math.min(width, mainContainer.getBoundingClientRect().width - 192)));
if (layout.inspectorWidth === null) mainContainer.style.removeProperty('--inspector-w');
else mainContainer.style.setProperty('--inspector-w', `${layout.inspectorWidth}px`);
divider.setAttribute('aria-valuenow', String(Math.round(inspector.getBoundingClientRect().width)));
}
divider.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
divider.setPointerCapture(e.pointerId);
divider.classList.add('dragging');
dividerDrag = true;
e.preventDefault();
});
divider.addEventListener('pointermove', (e) => {
if (dividerDrag) setInspectorWidth(mainContainer.getBoundingClientRect().right - e.clientX);
});
divider.addEventListener('lostpointercapture', () => {
if (!dividerDrag) return;
dividerDrag = false;
divider.classList.remove('dragging');
saveLayout();
});
divider.addEventListener('dblclick', () => {
setInspectorWidth(null);
saveLayout();
});
divider.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
// Not Prev/Next node
e.preventDefault();
e.stopPropagation();
setInspectorWidth(inspector.getBoundingClientRect().width + (e.key === 'ArrowLeft' ? 16 : -16));
saveLayout();
});
/**
* Zoom: the buttons and the wheel over the frame change ComfyUI's Zoom Level setting, which
* updates the glass. It is written once they rest for 250 ms, as each write saves the setting.
*/
const ZOOM_MIN = 100;
const ZOOM_MAX = 1000;
const ZOOM_STEP = 25;
// The zoom shown, null until ComfyUI sends it
let zoom = null;
let zoomTimer = null;
let wheelRest = 0;
function setZoom(value) {
if (zoom === null || connection !== 'connected') return;
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value / ZOOM_STEP) * ZOOM_STEP));
if (next === zoom) return;
zoom = next;
renderZoom();
clearTimeout(zoomTimer);
zoomTimer = setTimeout(() => {
zoomTimer = null;
channel.postMessage({ type: 'zoom', data: zoom });
}, 250);
}
function renderZoom() {
const live = zoom !== null && connection === 'connected';
zoomValue.textContent = zoom === null ? '–' : `${zoom}%`;
zoomOut.disabled = !live || zoom <= ZOOM_MIN;
zoomIn.disabled = !live || zoom >= ZOOM_MAX;
}
zoomOut.addEventListener('click', () => setZoom(zoom - ZOOM_STEP));
zoomIn.addEventListener('click', () => setZoom(zoom + ZOOM_STEP));
canvasSection.addEventListener('wheel', (e) => {
e.preventDefault();
// A mouse notch is about 100 px; a touchpad's small deltas add up to steps
wheelRest += e.deltaMode ? Math.sign(e.deltaY) * 100 : e.deltaY;
const steps = Math.trunc(wheelRest / 100);
wheelRest -= steps * 100;
if (steps) setZoom(zoom - steps * ZOOM_STEP);
}, { passive: false });
</script>
</body>