diff --git a/src/magnify-glass/MagnifyGlass.ts b/src/magnify-glass/MagnifyGlass.ts index 2adba98..b009327 100644 --- a/src/magnify-glass/MagnifyGlass.ts +++ b/src/magnify-glass/MagnifyGlass.ts @@ -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, diff --git a/src/magnify-glass/PopOutManager.ts b/src/magnify-glass/PopOutManager.ts index e7573b9..bf04a98 100644 --- a/src/magnify-glass/PopOutManager.ts +++ b/src/magnify-glass/PopOutManager.ts @@ -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) } }); diff --git a/tests/unit/PopOutConnection.test.ts b/tests/unit/PopOutConnection.test.ts index 3b65c1c..2875e5a 100644 --- a/tests/unit/PopOutConnection.test.ts +++ b/tests/unit/PopOutConnection.test.ts @@ -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; diff --git a/tests/unit/PopOutTheme.test.ts b/tests/unit/PopOutTheme.test.ts index 7ca0499..eaaac68 100644 --- a/tests/unit/PopOutTheme.test.ts +++ b/tests/unit/PopOutTheme.test.ts @@ -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(() => { diff --git a/tests/unit/PopOutViewer.test.ts b/tests/unit/PopOutViewer.test.ts index 859473a..0f1edb8 100644 --- a/tests/unit/PopOutViewer.test.ts +++ b/tests/unit/PopOutViewer.test.ts @@ -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, 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); diff --git a/web/magnify-glass/MagnifyGlass.js b/web/magnify-glass/MagnifyGlass.js index bb811e9..df37907 100644 --- a/web/magnify-glass/MagnifyGlass.js +++ b/web/magnify-glass/MagnifyGlass.js @@ -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, diff --git a/web/magnify-glass/PopOutManager.js b/web/magnify-glass/PopOutManager.js index 9ea342c..8fd963e 100644 --- a/web/magnify-glass/PopOutManager.js +++ b/web/magnify-glass/PopOutManager.js @@ -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) } }); diff --git a/web/popout-viewer.html b/web/popout-viewer.html index 49a68c1..9d5b8b4 100644 --- a/web/popout-viewer.html +++ b/web/popout-viewer.html @@ -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 @@ Connecting... +
+ + – + +
+
+ + + +