feat: Add pop-out viewer for multi-monitor magnify glass display.

This commit is contained in:
AEmotionStudio
2026-01-02 23:55:32 -08:00
parent 570397e9a9
commit fed97c938c
20 changed files with 925 additions and 9 deletions
+12
View File
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.0] - 2026-01-02
### Added
- **Multi-Monitor Pop-Out Tab**: Open the magnified view in a separate browser tab that can be detached and moved to a secondary monitor.
- Pop-out button appears on hover in the top-right corner of the glass.
- Keyboard shortcut `Shift+P` to toggle pop-out when magnifier is active.
- Real-time sync at 30fps using BroadcastChannel API.
- Professional dark-themed viewer with connection status indicator.
- Viewer page automatically syncs glass shape and border styling.
---
## [1.6.0] - 2025-01-02
### Added
+6
View File
@@ -152,6 +152,12 @@ export class EventHandler {
config.saveOffsets();
this.magnifyGlass.updateMagnifiedView();
}
// Pop-out tab toggle (Shift+P)
if (e.shiftKey && e.key.toLowerCase() === 'p') {
this.magnifyGlass.popOutManager.toggle();
e.preventDefault();
}
}
}
+15 -1
View File
@@ -14,6 +14,7 @@ import { UiManager } from './UiManager';
import { WebGLRenderer } from './WebGLRenderer';
import { DebugManager } from './DebugManager';
import { EventHandler } from './EventHandler';
import { PopOutManager } from './PopOutManager';
// External ComfyUI globals
declare const app: ComfyApp;
@@ -30,6 +31,7 @@ export class MagnifyGlass {
renderer: WebGLRenderer | null;
debugger: DebugManager;
eventHandler: EventHandler;
popOutManager: PopOutManager;
/** The LiteGraph canvas */
litegraphCanvas: HTMLCanvasElement | null;
@@ -46,7 +48,13 @@ export class MagnifyGlass {
constructor() {
this.config = new ConfigManager();
this.state = new MagnifierState();
this.ui = new UiManager(this.config, this.state, () => this.toggle());
this.popOutManager = new PopOutManager();
this.ui = new UiManager(
this.config,
this.state,
() => this.toggle(),
() => this.popOutManager.toggle()
);
this.renderer = null;
this.debugger = new DebugManager(this.config, this.state, this.ui);
this.eventHandler = new EventHandler(this);
@@ -162,6 +170,11 @@ export class MagnifyGlass {
// Render HTML overlays
this.renderHtmlOverlays();
// Send frame to pop-out tab if open
if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) {
this.popOutManager.sendFrame(this.ui.glassCanvas);
}
this.state.isRenderScheduled = false;
});
}
@@ -440,6 +453,7 @@ export class MagnifyGlass {
*/
cleanup(): void {
this.eventHandler.detachListeners();
this.popOutManager.cleanup();
this.ui.cleanup();
}
}
+245
View File
@@ -0,0 +1,245 @@
/**
* ComfyUI MagnifyGlass - PopOutManager (TypeScript)
*
* Manages pop-out tab functionality for multi-monitor viewing.
* Uses BroadcastChannel API for efficient cross-tab communication.
*/
import { BROADCAST_CHANNEL_NAME } from '../shared/constants';
import { Logger } from '../shared/logger';
/**
* Message types for BroadcastChannel communication
*/
interface PopOutMessage {
type: 'frame' | 'config' | 'close' | 'ping' | 'pong';
data?: string | PopOutConfig;
timestamp?: number;
}
interface PopOutConfig {
glassSize: number;
borderColor: string;
borderWidth: number;
glassShape: string;
}
/**
* PopOutManager class.
* Handles opening, closing, and syncing with an external browser tab.
*/
export class PopOutManager {
private channel: BroadcastChannel | null = null;
private isOpen: boolean = false;
private lastPongTime: number = 0;
private pingInterval: number | null = null;
private viewerUrl: string;
/** Throttle frame sending to ~30fps */
private lastFrameTime: number = 0;
private readonly FRAME_INTERVAL = 33; // ~30fps
/** Connection timeout (ms) */
private readonly CONNECTION_TIMEOUT = 5000;
constructor() {
// Get the viewer URL relative to the extension
// The viewer HTML will be in the web directory
this.viewerUrl = this.getViewerUrl();
this.initChannel();
}
/**
* Get the URL for the pop-out viewer page.
*/
private getViewerUrl(): string {
// Find the extension's base URL from the loaded scripts
const scripts = document.querySelectorAll('script[src*="magnify"]');
if (scripts.length > 0) {
const src = (scripts[0] as HTMLScriptElement).src;
const baseUrl = src.substring(0, src.lastIndexOf('/'));
return `${baseUrl}/popout-viewer.html`;
}
// Fallback: assume standard ComfyUI extension path
return '/extensions/comfyui-magnifyglass/popout-viewer.html';
}
/**
* Initialize the BroadcastChannel.
*/
private initChannel(): void {
try {
this.channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
this.channel.onmessage = (event: MessageEvent<PopOutMessage>) => {
this.handleMessage(event.data);
};
Logger.debug('[PopOut] BroadcastChannel initialized');
} catch (e) {
Logger.error('[PopOut] Failed to create BroadcastChannel:', e);
}
}
/**
* Handle incoming messages from the viewer tab.
*/
private handleMessage(message: PopOutMessage): void {
switch (message.type) {
case 'pong':
this.lastPongTime = Date.now();
if (!this.isOpen) {
this.isOpen = true;
Logger.debug('[PopOut] Viewer tab connected');
}
break;
case 'close':
this.isOpen = false;
this.stopPing();
Logger.debug('[PopOut] Viewer tab closed');
break;
}
}
/**
* Open the pop-out viewer in a new tab.
*/
open(): void {
if (this.isOpen) {
Logger.debug('[PopOut] Tab already open');
return;
}
// Open new tab
const newTab = window.open(this.viewerUrl, '_blank');
if (!newTab) {
Logger.error('[PopOut] Failed to open new tab - 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) return;
this.sendMessage({ type: 'close' });
this.isOpen = false;
this.stopPing();
Logger.debug('[PopOut] Sent close message to viewer');
}
/**
* Toggle the pop-out state.
*/
toggle(): void {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
/**
* Check if the pop-out tab is currently open.
*/
isPopOutOpen(): boolean {
return this.isOpen;
}
/**
* Send a frame to the pop-out viewer.
* Throttled to ~30fps for performance.
* @param canvas - The source canvas to send
*/
sendFrame(canvas: HTMLCanvasElement): void {
if (!this.isOpen || !this.channel) return;
// Throttle frame rate
const now = Date.now();
if (now - this.lastFrameTime < this.FRAME_INTERVAL) {
return;
}
this.lastFrameTime = now;
try {
// Convert canvas to data URL (efficient for small canvases)
const dataUrl = canvas.toDataURL('image/png');
this.sendMessage({
type: 'frame',
data: dataUrl,
timestamp: now
});
} catch (e) {
Logger.error('[PopOut] Failed to send frame:', e);
}
}
/**
* Send configuration to the pop-out viewer.
*/
sendConfig(config: PopOutConfig): void {
if (!this.channel) return;
this.sendMessage({
type: 'config',
data: config
});
}
/**
* Send a message through the BroadcastChannel.
*/
private sendMessage(message: PopOutMessage): void {
if (!this.channel) return;
try {
this.channel.postMessage(message);
} catch (e) {
Logger.error('[PopOut] Failed to send message:', e);
}
}
/**
* Start pinging to check connection.
*/
private startPing(): void {
this.stopPing();
this.pingInterval = setInterval(() => {
this.sendMessage({ type: 'ping', timestamp: Date.now() });
// Check for connection timeout
if (this.isOpen && Date.now() - this.lastPongTime > this.CONNECTION_TIMEOUT) {
Logger.debug('[PopOut] Connection timeout, marking as closed');
this.isOpen = false;
this.stopPing();
}
}, 1000);
}
/**
* Stop pinging.
*/
private stopPing(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
/**
* Cleanup resources.
*/
cleanup(): void {
this.close();
this.stopPing();
if (this.channel) {
this.channel.close();
this.channel = null;
}
Logger.debug('[PopOut] Cleaned up');
}
}
+62 -1
View File
@@ -22,9 +22,11 @@ export class UiManager {
debugCanvas: HTMLCanvasElement | null;
debugCtx: CanvasRenderingContext2D | null;
htmlOverlayContainer: HTMLDivElement | null;
popOutButton: HTMLButtonElement | null;
onToggle: (() => void) | undefined;
onPopOut: (() => void) | undefined;
constructor(config: ConfigManager, state: MagnifierState, onToggle?: () => void) {
constructor(config: ConfigManager, state: MagnifierState, onToggle?: () => void, onPopOut?: () => void) {
this.config = config;
this.state = state;
this.glassDiv = null;
@@ -32,7 +34,9 @@ export class UiManager {
this.debugCanvas = null;
this.debugCtx = null;
this.htmlOverlayContainer = null;
this.popOutButton = null;
this.onToggle = onToggle;
this.onPopOut = onPopOut;
}
/**
@@ -78,6 +82,63 @@ export class UiManager {
`;
this.glassDiv.appendChild(this.htmlOverlayContainer);
// Create pop-out button
this.popOutButton = document.createElement("button");
this.popOutButton.id = "comfyui-magnify-popout-btn";
this.popOutButton.title = "Open in New Tab (Shift+P)";
this.popOutButton.innerHTML = Icons.externalLink;
this.popOutButton.style.cssText = `
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
padding: 4px;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.4);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease, background 0.2s ease, color 0.2s ease;
z-index: 10;
`;
this.popOutButton.addEventListener('click', (e) => {
e.stopPropagation();
if (this.onPopOut) {
this.onPopOut();
}
});
this.popOutButton.addEventListener('mouseenter', () => {
if (this.popOutButton) {
this.popOutButton.style.background = 'rgba(99, 102, 241, 0.6)';
this.popOutButton.style.color = '#fff';
}
});
this.popOutButton.addEventListener('mouseleave', () => {
if (this.popOutButton) {
this.popOutButton.style.background = 'rgba(0, 0, 0, 0.4)';
this.popOutButton.style.color = 'rgba(255, 255, 255, 0.7)';
}
});
this.glassDiv.appendChild(this.popOutButton);
// Show pop-out button on glass hover
this.glassDiv.addEventListener('mouseenter', () => {
if (this.popOutButton) {
this.popOutButton.style.opacity = '1';
}
});
this.glassDiv.addEventListener('mouseleave', () => {
if (this.popOutButton) {
this.popOutButton.style.opacity = '0';
}
});
document.body.appendChild(this.glassDiv);
// Create debug canvas if debug mode is enabled
+4
View File
@@ -84,3 +84,7 @@ export const STORAGE_KEYS = {
OFFSET_X: 'comfyui_magnify_offset_x',
OFFSET_Y: 'comfyui_magnify_offset_y'
} as const;
// BroadcastChannel for pop-out tab communication
export const BROADCAST_CHANNEL_NAME = 'comfyui-magnifyglass-popout';
+7
View File
@@ -82,6 +82,13 @@ export const Icons = {
// Magnet icon - persist/sticky mode
magnet: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 15-4-4 6.75-6.77a7.79 7.79 0 0 1 11 11L13 22l-4-4 6.39-6.36a2.2 2.2 0 0 0-3.1-3.13l-6.29 6.49Z"/>
</svg>`,
// External link icon - pop out to new tab
externalLink: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>`
};
+4
View File
@@ -117,6 +117,10 @@ class EventHandler {
config.saveOffsets();
this.magnifyGlass.updateMagnifiedView();
}
if (e.shiftKey && e.key.toLowerCase() === "p") {
this.magnifyGlass.popOutManager.toggle();
e.preventDefault();
}
}
}
/**
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -11,6 +11,7 @@ import { UiManager } from "./UiManager.js";
import { WebGLRenderer } from "./WebGLRenderer.js";
import { DebugManager } from "./DebugManager.js";
import { EventHandler } from "./EventHandler.js";
import { PopOutManager } from "./PopOutManager.js";
class MagnifyGlass {
constructor() {
__publicField(this, "config");
@@ -19,6 +20,7 @@ class MagnifyGlass {
__publicField(this, "renderer");
__publicField(this, "debugger");
__publicField(this, "eventHandler");
__publicField(this, "popOutManager");
/** The LiteGraph canvas */
__publicField(this, "litegraphCanvas");
/** Last known mouse position for better initial positioning */
@@ -29,7 +31,13 @@ class MagnifyGlass {
__publicField(this, "currentMediaElement");
this.config = new ConfigManager();
this.state = new MagnifierState();
this.ui = new UiManager(this.config, this.state, () => this.toggle());
this.popOutManager = new PopOutManager();
this.ui = new UiManager(
this.config,
this.state,
() => this.toggle(),
() => this.popOutManager.toggle()
);
this.renderer = null;
this.debugger = new DebugManager(this.config, this.state, this.ui);
this.eventHandler = new EventHandler(this);
@@ -104,6 +112,9 @@ class MagnifyGlass {
this.renderer.render(this.litegraphCanvas);
this.debugger.updateDebugView();
this.renderHtmlOverlays();
if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) {
this.popOutManager.sendFrame(this.ui.glassCanvas);
}
this.state.isRenderScheduled = false;
});
}
@@ -330,6 +341,7 @@ class MagnifyGlass {
*/
cleanup() {
this.eventHandler.detachListeners();
this.popOutManager.cleanup();
this.ui.cleanup();
}
}
File diff suppressed because one or more lines are too long
+192
View File
@@ -0,0 +1,192 @@
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 { BROADCAST_CHANNEL_NAME } from "../shared/constants.js";
import { Logger } from "../shared/logger.js";
class PopOutManager {
constructor() {
__publicField(this, "channel", null);
__publicField(this, "isOpen", false);
__publicField(this, "lastPongTime", 0);
__publicField(this, "pingInterval", null);
__publicField(this, "viewerUrl");
/** Throttle frame sending to ~30fps */
__publicField(this, "lastFrameTime", 0);
__publicField(this, "FRAME_INTERVAL", 33);
// ~30fps
/** Connection timeout (ms) */
__publicField(this, "CONNECTION_TIMEOUT", 5e3);
this.viewerUrl = this.getViewerUrl();
this.initChannel();
}
/**
* Get the URL for the pop-out viewer page.
*/
getViewerUrl() {
const scripts = document.querySelectorAll('script[src*="magnify"]');
if (scripts.length > 0) {
const src = scripts[0].src;
const baseUrl = src.substring(0, src.lastIndexOf("/"));
return `${baseUrl}/popout-viewer.html`;
}
return "/extensions/comfyui-magnifyglass/popout-viewer.html";
}
/**
* Initialize the BroadcastChannel.
*/
initChannel() {
try {
this.channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
this.channel.onmessage = (event) => {
this.handleMessage(event.data);
};
Logger.debug("[PopOut] BroadcastChannel initialized");
} catch (e) {
Logger.error("[PopOut] Failed to create BroadcastChannel:", e);
}
}
/**
* Handle incoming messages from the viewer tab.
*/
handleMessage(message) {
switch (message.type) {
case "pong":
this.lastPongTime = Date.now();
if (!this.isOpen) {
this.isOpen = true;
Logger.debug("[PopOut] Viewer tab connected");
}
break;
case "close":
this.isOpen = false;
this.stopPing();
Logger.debug("[PopOut] Viewer tab closed");
break;
}
}
/**
* Open the pop-out viewer in a new tab.
*/
open() {
if (this.isOpen) {
Logger.debug("[PopOut] Tab already open");
return;
}
const newTab = window.open(this.viewerUrl, "_blank");
if (!newTab) {
Logger.error("[PopOut] Failed to open new tab - popup may be blocked");
return;
}
this.startPing();
Logger.debug("[PopOut] Opening viewer tab...");
}
/**
* Close the pop-out viewer tab.
*/
close() {
if (!this.isOpen) return;
this.sendMessage({ type: "close" });
this.isOpen = false;
this.stopPing();
Logger.debug("[PopOut] Sent close message to viewer");
}
/**
* Toggle the pop-out state.
*/
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
/**
* Check if the pop-out tab is currently open.
*/
isPopOutOpen() {
return this.isOpen;
}
/**
* Send a frame to the pop-out viewer.
* Throttled to ~30fps for performance.
* @param canvas - The source canvas to send
*/
sendFrame(canvas) {
if (!this.isOpen || !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
});
} catch (e) {
Logger.error("[PopOut] Failed to send frame:", e);
}
}
/**
* Send configuration to the pop-out viewer.
*/
sendConfig(config) {
if (!this.channel) return;
this.sendMessage({
type: "config",
data: config
});
}
/**
* Send a message through the BroadcastChannel.
*/
sendMessage(message) {
if (!this.channel) return;
try {
this.channel.postMessage(message);
} catch (e) {
Logger.error("[PopOut] Failed to send message:", e);
}
}
/**
* Start pinging to check connection.
*/
startPing() {
this.stopPing();
this.pingInterval = setInterval(() => {
this.sendMessage({ type: "ping", timestamp: Date.now() });
if (this.isOpen && Date.now() - this.lastPongTime > this.CONNECTION_TIMEOUT) {
Logger.debug("[PopOut] Connection timeout, marking as closed");
this.isOpen = false;
this.stopPing();
}
}, 1e3);
}
/**
* Stop pinging.
*/
stopPing() {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
/**
* Cleanup resources.
*/
cleanup() {
this.close();
this.stopPing();
if (this.channel) {
this.channel.close();
this.channel = null;
}
Logger.debug("[PopOut] Cleaned up");
}
}
export {
PopOutManager
};
//# sourceMappingURL=PopOutManager.js.map
File diff suppressed because one or more lines are too long
+58 -1
View File
@@ -5,7 +5,7 @@ import { Z_INDEX, DEFAULT_PADDING } from "../shared/constants.js";
import { Icons } from "../shared/icons.js";
import { Logger } from "../shared/logger.js";
class UiManager {
constructor(config, state, onToggle) {
constructor(config, state, onToggle, onPopOut) {
__publicField(this, "config");
__publicField(this, "state");
__publicField(this, "glassDiv");
@@ -13,7 +13,9 @@ class UiManager {
__publicField(this, "debugCanvas");
__publicField(this, "debugCtx");
__publicField(this, "htmlOverlayContainer");
__publicField(this, "popOutButton");
__publicField(this, "onToggle");
__publicField(this, "onPopOut");
this.config = config;
this.state = state;
this.glassDiv = null;
@@ -21,7 +23,9 @@ class UiManager {
this.debugCanvas = null;
this.debugCtx = null;
this.htmlOverlayContainer = null;
this.popOutButton = null;
this.onToggle = onToggle;
this.onPopOut = onPopOut;
}
/**
* Create all DOM elements for the magnifying glass.
@@ -60,6 +64,59 @@ class UiManager {
overflow: hidden;
`;
this.glassDiv.appendChild(this.htmlOverlayContainer);
this.popOutButton = document.createElement("button");
this.popOutButton.id = "comfyui-magnify-popout-btn";
this.popOutButton.title = "Open in New Tab (Shift+P)";
this.popOutButton.innerHTML = Icons.externalLink;
this.popOutButton.style.cssText = `
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
padding: 4px;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.4);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease, background 0.2s ease, color 0.2s ease;
z-index: 10;
`;
this.popOutButton.addEventListener("click", (e) => {
e.stopPropagation();
if (this.onPopOut) {
this.onPopOut();
}
});
this.popOutButton.addEventListener("mouseenter", () => {
if (this.popOutButton) {
this.popOutButton.style.background = "rgba(99, 102, 241, 0.6)";
this.popOutButton.style.color = "#fff";
}
});
this.popOutButton.addEventListener("mouseleave", () => {
if (this.popOutButton) {
this.popOutButton.style.background = "rgba(0, 0, 0, 0.4)";
this.popOutButton.style.color = "rgba(255, 255, 255, 0.7)";
}
});
this.glassDiv.appendChild(this.popOutButton);
this.glassDiv.addEventListener("mouseenter", () => {
if (this.popOutButton) {
this.popOutButton.style.opacity = "1";
}
});
this.glassDiv.addEventListener("mouseleave", () => {
if (this.popOutButton) {
this.popOutButton.style.opacity = "0";
}
});
document.body.appendChild(this.glassDiv);
if (this.config.debugMode) {
this.createDebugCanvas();
File diff suppressed because one or more lines are too long
+293
View File
@@ -0,0 +1,293 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔍 MagnifyGlass Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f0f23 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: #e0e0e0;
overflow: hidden;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 40px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
z-index: 100;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: #93c5fd;
}
.header-title svg {
width: 18px;
height: 18px;
stroke: #93c5fd;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6b7280;
transition: background-color 0.3s ease;
}
.status-dot.connected {
background: #22c55e;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-text {
color: #9ca3af;
}
.canvas-container {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
width: 100%;
padding: 60px 20px 20px;
}
#viewer-canvas {
max-width: 100%;
max-height: calc(100vh - 80px);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 2px solid rgba(99, 102, 241, 0.3);
background: rgba(0, 0, 0, 0.2);
}
.disconnected-message {
display: none;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
.disconnected-message.show {
display: flex;
}
.disconnected-icon {
font-size: 48px;
opacity: 0.6;
}
.disconnected-title {
font-size: 20px;
color: #9ca3af;
}
.disconnected-hint {
font-size: 14px;
color: #6b7280;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.connecting .status-dot {
animation: pulse 1.5s ease-in-out infinite;
background: #fbbf24;
}
</style>
</head>
<body class="connecting">
<header class="header">
<div class="header-title">
<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">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<span>MagnifyGlass Viewer</span>
</div>
<div class="status">
<span class="status-dot"></span>
<span class="status-text">Connecting...</span>
</div>
</header>
<div class="canvas-container">
<canvas id="viewer-canvas" width="300" height="300"></canvas>
<div class="disconnected-message">
<span class="disconnected-icon">🔍</span>
<p class="disconnected-title">Not Connected</p>
<p class="disconnected-hint">Activate MagnifyGlass in ComfyUI to start viewing</p>
</div>
</div>
<script>
const CHANNEL_NAME = 'comfyui-magnifyglass-popout';
// DOM Elements
const canvas = document.getElementById('viewer-canvas');
const ctx = canvas.getContext('2d');
const statusDot = document.querySelector('.status-dot');
const statusText = document.querySelector('.status-text');
const disconnectedMessage = document.querySelector('.disconnected-message');
const body = document.body;
// State
let isConnected = false;
let lastFrameTime = 0;
let frameImage = new Image();
let connectionCheckInterval = null;
// Initialize BroadcastChannel
const channel = new BroadcastChannel(CHANNEL_NAME);
channel.onmessage = (event) => {
const message = event.data;
switch (message.type) {
case 'frame':
handleFrame(message.data);
break;
case 'config':
handleConfig(message.data);
break;
case 'ping':
// Respond with pong
channel.postMessage({ type: 'pong', timestamp: Date.now() });
markConnected();
break;
case 'close':
handleClose();
break;
}
};
function handleFrame(dataUrl) {
if (!dataUrl) return;
frameImage.onload = () => {
// Resize canvas to match image dimensions
if (canvas.width !== frameImage.width || canvas.height !== frameImage.height) {
canvas.width = frameImage.width;
canvas.height = frameImage.height;
}
// Draw the frame
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(frameImage, 0, 0);
};
frameImage.src = dataUrl;
lastFrameTime = Date.now();
markConnected();
}
function handleConfig(config) {
if (!config) return;
// Apply styling based on config
if (config.borderColor) {
canvas.style.borderColor = config.borderColor;
}
if (config.borderWidth) {
canvas.style.borderWidth = `${config.borderWidth}px`;
}
if (config.glassShape === 'Circle') {
canvas.style.borderRadius = '50%';
} else if (config.glassShape === 'Rounded Square') {
canvas.style.borderRadius = '20%';
} else {
canvas.style.borderRadius = '8px';
}
}
function handleClose() {
markDisconnected();
// Optionally close this tab
// window.close();
}
function markConnected() {
if (!isConnected) {
isConnected = true;
body.classList.remove('connecting');
statusDot.classList.add('connected');
statusText.textContent = 'Connected';
canvas.style.display = 'block';
disconnectedMessage.classList.remove('show');
}
}
function markDisconnected() {
isConnected = false;
body.classList.add('connecting');
statusDot.classList.remove('connected');
statusText.textContent = 'Disconnected';
canvas.style.display = 'none';
disconnectedMessage.classList.add('show');
}
// Check connection status periodically
connectionCheckInterval = setInterval(() => {
if (isConnected && Date.now() - lastFrameTime > 3000) {
markDisconnected();
}
}, 1000);
// Initial ping to source
channel.postMessage({ type: 'pong', timestamp: Date.now() });
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
channel.postMessage({ type: 'close' });
channel.close();
clearInterval(connectionCheckInterval);
});
// Handle Escape key to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
channel.postMessage({ type: 'close' });
window.close();
}
});
</script>
</body>
</html>
+2
View File
@@ -40,8 +40,10 @@ const STORAGE_KEYS = {
OFFSET_X: "comfyui_magnify_offset_x",
OFFSET_Y: "comfyui_magnify_offset_y"
};
const BROADCAST_CHANNEL_NAME = "comfyui-magnifyglass-popout";
export {
ACTIVATION_KEYS,
BROADCAST_CHANNEL_NAME,
DEFAULT_BORDER_COLOR,
DEFAULT_BORDER_WIDTH,
DEFAULT_GLASS_SIZE,
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"constants.js","sources":["../../src/shared/constants.ts"],"sourcesContent":["/**\n * ComfyUI MagnifyGlass - Shared Constants\n * \n * Configuration constants shared between magnify glass and info panel modules.\n */\n\n// Glass positioning options\nexport const GLASS_POSITIONS = [\n \"Top\",\n \"Bottom\",\n \"Left\",\n \"Right\",\n \"Top-Left\",\n \"Top-Right\",\n \"Bottom-Left\",\n \"Bottom-Right\"\n] as const;\n\nexport type GlassPosition = typeof GLASS_POSITIONS[number];\n\n// Glass shape options\nexport const GLASS_SHAPES = [\n \"Circle\",\n \"Square\",\n \"Rounded Square\"\n] as const;\n\nexport type GlassShape = typeof GLASS_SHAPES[number];\n\n// Texture filtering modes\nexport const TEXTURE_FILTERS = [\n \"Linear\",\n \"Nearest\"\n] as const;\n\nexport type TextureFilter = typeof TEXTURE_FILTERS[number];\n\n// Info panel positions relative to glass\nexport const PANEL_POSITIONS = [\n \"Top\",\n \"Bottom\",\n \"Left\",\n \"Right\"\n] as const;\n\nexport type PanelPosition = typeof PANEL_POSITIONS[number];\n\n// Available keys for settings\nexport const ACTIVATION_KEYS = [\"x\", \"z\", \"m\", \"q\", \"v\", \"c\"] as const;\nexport const RESET_KEYS = [\"r\", \"o\", \"p\", \"k\", \"l\"] as const;\nexport const TOGGLE_FOLLOW_KEYS = [\"f\", \"g\", \"h\", \"j\", \"k\"] as const;\n\n// UI Constants\nexport const DEFAULT_PADDING = 4;\nexport const DEFAULT_GLASS_Y_OFFSET = 110; // Starting Y position offset (below top bar with 3px gap)\nexport const DEFAULT_GLASS_SIZE = 300;\nexport const DEFAULT_ZOOM_FACTOR = 300; // 3x as percentage\nexport const DEFAULT_BORDER_WIDTH = 1;\nexport const DEFAULT_BORDER_COLOR = \"#6b7280\";\nexport const DEFAULT_OFFSET_STEP = 5;\n\n// Panel Constants\nexport const DEFAULT_PANEL_WIDTH = 300;\nexport const DEFAULT_PANEL_MAX_HEIGHT = 300;\nexport const DEFAULT_PANEL_OPACITY = 100;\n\n// Z-Index hierarchy\nexport const Z_INDEX = {\n GLASS: 98999,\n DEBUG: 99000,\n PANEL: 99999,\n CONTROLS: 99998\n} as const;\n\n// Animation durations (ms)\nexport const ANIMATION = {\n PANEL_TRANSITION: 300,\n AUTO_COLLAPSE_DELAY: 1500,\n THEME_DETECTION_INTERVAL: 2000\n} as const;\n\n// LocalStorage keys\nexport const STORAGE_KEYS = {\n OFFSET_X: 'comfyui_magnify_offset_x',\n OFFSET_Y: 'comfyui_magnify_offset_y'\n} as const;\n"],"names":[],"mappings":"AAOO,MAAM,kBAAkB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ;AAKO,MAAM,eAAe;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AACJ;AAaO,MAAM,kBAAkB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ;AAKO,MAAM,kBAAkB,CAAC,KAAK,KAAK,KAAK,KAAK,KAAK,GAAG;AACrD,MAAM,aAAa,CAAC,KAAK,KAAK,KAAK,KAAK,GAAG;AAC3C,MAAM,qBAAqB,CAAC,KAAK,KAAK,KAAK,KAAK,GAAG;AAGnD,MAAM,kBAAkB;AACxB,MAAM,yBAAyB;AAC/B,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;AAC5B,MAAM,uBAAuB;AAC7B,MAAM,uBAAuB;AAC7B,MAAM,sBAAsB;AAG5B,MAAM,sBAAsB;AAC5B,MAAM,2BAA2B;AACjC,MAAM,wBAAwB;AAG9B,MAAM,UAAU;AAAA,EACnB,OAAO;AAAA,EACP,OAAO;AAGX;AAUO,MAAM,eAAe;AAAA,EACxB,UAAU;AAAA,EACV,UAAU;AACd;"}
{"version":3,"file":"constants.js","sources":["../../src/shared/constants.ts"],"sourcesContent":["/**\n * ComfyUI MagnifyGlass - Shared Constants\n * \n * Configuration constants shared between magnify glass and info panel modules.\n */\n\n// Glass positioning options\nexport const GLASS_POSITIONS = [\n \"Top\",\n \"Bottom\",\n \"Left\",\n \"Right\",\n \"Top-Left\",\n \"Top-Right\",\n \"Bottom-Left\",\n \"Bottom-Right\"\n] as const;\n\nexport type GlassPosition = typeof GLASS_POSITIONS[number];\n\n// Glass shape options\nexport const GLASS_SHAPES = [\n \"Circle\",\n \"Square\",\n \"Rounded Square\"\n] as const;\n\nexport type GlassShape = typeof GLASS_SHAPES[number];\n\n// Texture filtering modes\nexport const TEXTURE_FILTERS = [\n \"Linear\",\n \"Nearest\"\n] as const;\n\nexport type TextureFilter = typeof TEXTURE_FILTERS[number];\n\n// Info panel positions relative to glass\nexport const PANEL_POSITIONS = [\n \"Top\",\n \"Bottom\",\n \"Left\",\n \"Right\"\n] as const;\n\nexport type PanelPosition = typeof PANEL_POSITIONS[number];\n\n// Available keys for settings\nexport const ACTIVATION_KEYS = [\"x\", \"z\", \"m\", \"q\", \"v\", \"c\"] as const;\nexport const RESET_KEYS = [\"r\", \"o\", \"p\", \"k\", \"l\"] as const;\nexport const TOGGLE_FOLLOW_KEYS = [\"f\", \"g\", \"h\", \"j\", \"k\"] as const;\n\n// UI Constants\nexport const DEFAULT_PADDING = 4;\nexport const DEFAULT_GLASS_Y_OFFSET = 110; // Starting Y position offset (below top bar with 3px gap)\nexport const DEFAULT_GLASS_SIZE = 300;\nexport const DEFAULT_ZOOM_FACTOR = 300; // 3x as percentage\nexport const DEFAULT_BORDER_WIDTH = 1;\nexport const DEFAULT_BORDER_COLOR = \"#6b7280\";\nexport const DEFAULT_OFFSET_STEP = 5;\n\n// Panel Constants\nexport const DEFAULT_PANEL_WIDTH = 300;\nexport const DEFAULT_PANEL_MAX_HEIGHT = 300;\nexport const DEFAULT_PANEL_OPACITY = 100;\n\n// Z-Index hierarchy\nexport const Z_INDEX = {\n GLASS: 98999,\n DEBUG: 99000,\n PANEL: 99999,\n CONTROLS: 99998\n} as const;\n\n// Animation durations (ms)\nexport const ANIMATION = {\n PANEL_TRANSITION: 300,\n AUTO_COLLAPSE_DELAY: 1500,\n THEME_DETECTION_INTERVAL: 2000\n} as const;\n\n// LocalStorage keys\nexport const STORAGE_KEYS = {\n OFFSET_X: 'comfyui_magnify_offset_x',\n OFFSET_Y: 'comfyui_magnify_offset_y'\n} as const;\n\n// BroadcastChannel for pop-out tab communication\nexport const BROADCAST_CHANNEL_NAME = 'comfyui-magnifyglass-popout';\n\n"],"names":[],"mappings":"AAOO,MAAM,kBAAkB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ;AAKO,MAAM,eAAe;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AACJ;AAaO,MAAM,kBAAkB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ;AAKO,MAAM,kBAAkB,CAAC,KAAK,KAAK,KAAK,KAAK,KAAK,GAAG;AACrD,MAAM,aAAa,CAAC,KAAK,KAAK,KAAK,KAAK,GAAG;AAC3C,MAAM,qBAAqB,CAAC,KAAK,KAAK,KAAK,KAAK,GAAG;AAGnD,MAAM,kBAAkB;AACxB,MAAM,yBAAyB;AAC/B,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;AAC5B,MAAM,uBAAuB;AAC7B,MAAM,uBAAuB;AAC7B,MAAM,sBAAsB;AAG5B,MAAM,sBAAsB;AAC5B,MAAM,2BAA2B;AACjC,MAAM,wBAAwB;AAG9B,MAAM,UAAU;AAAA,EACnB,OAAO;AAAA,EACP,OAAO;AAGX;AAUO,MAAM,eAAe;AAAA,EACxB,UAAU;AAAA,EACV,UAAU;AACd;AAGO,MAAM,yBAAyB;"}
+6
View File
@@ -57,6 +57,12 @@ const Icons = {
// Magnet icon - persist/sticky mode
magnet: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 15-4-4 6.75-6.77a7.79 7.79 0 0 1 11 11L13 22l-4-4 6.39-6.36a2.2 2.2 0 0 0-3.1-3.13l-6.29 6.49Z"/>
</svg>`,
// External link icon - pop out to new tab
externalLink: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>`
};
export {
File diff suppressed because one or more lines are too long