Files
AEmotionStudio-ComfyUI-Magn…/web/popout-viewer.html
T

1528 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
:root {
--bg-primary: #0f0f14;
--bg-secondary: #16161d;
--bg-tertiary: #1e1e28;
--bg-elevated: #252532;
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.1);
--border-accent: rgba(99, 102, 241, 0.4);
--text-primary: #f0f0f5;
--text-secondary: #9ca3af;
--text-tertiary: #6b7280;
--accent-blue: #6366f1;
--accent-cyan: #22d3ee;
--accent-green: #22c55e;
--accent-amber: #f59e0b;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-lg: 14px;
}
/* Light Theme Overrides */
[data-theme="light"] {
--bg-primary: #f9f9fb;
--bg-secondary: #ffffff;
--bg-tertiary: #f3f4f6;
--bg-elevated: #e5e7eb;
--border-subtle: rgba(0, 0, 0, 0.05);
--border-default: rgba(0, 0, 0, 0.1);
--border-accent: rgba(99, 102, 241, 0.3);
--text-primary: #111827;
--text-secondary: #4b5563;
--text-tertiary: #9ca3af;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
/* Solarized Theme */
[data-theme="solarized"] {
--bg-primary: #002b36;
--bg-secondary: #073642;
--bg-tertiary: #073642;
--bg-elevated: #002b36;
--border-subtle: rgba(88, 110, 117, 0.3);
--border-default: rgba(88, 110, 117, 0.5);
--text-primary: #839496;
--text-secondary: #586e75;
--text-tertiary: #586e75;
--accent-blue: #268bd2;
--accent-cyan: #2aa198;
--accent-green: #859900;
}
/* Arc Theme */
[data-theme="arc"] {
--bg-primary: #383c4a;
--bg-secondary: #404552;
--bg-tertiary: #2f343f;
--bg-elevated: #4b5162;
--border-subtle: rgba(0, 0, 0, 0.2);
--border-default: rgba(0, 0, 0, 0.3);
--text-primary: #d3dae3;
--text-secondary: #a0a8b9;
--text-tertiary: #7c818c;
--accent-blue: #5294e2;
}
/* Nord Theme */
[data-theme="nord"] {
--bg-primary: #2e3440;
--bg-secondary: #3b4252;
--bg-tertiary: #434c5e;
--bg-elevated: #4c566a;
--border-subtle: rgba(0, 0, 0, 0.2);
--border-default: rgba(236, 239, 244, 0.1);
--text-primary: #d8dee9;
--text-secondary: #e5e9f0;
--text-tertiary: #4c566a;
--accent-blue: #81a1c1;
--accent-cyan: #88c0d0;
--accent-green: #a3be8c;
}
/* GitHub Theme */
[data-theme="github"] {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #ffffff;
--bg-elevated: #f6f8fa;
--border-subtle: rgba(27, 31, 35, 0.05);
--border-default: rgba(27, 31, 35, 0.1);
--text-primary: #24292e;
--text-secondary: #586069;
--text-tertiary: #6a737d;
--accent-blue: #0366d6;
}
/* Gruvbox Theme */
[data-theme="gruvbox"] {
--bg-primary: #282828;
--bg-secondary: #3c3836;
--bg-tertiary: #504945;
--bg-elevated: #665c54;
--border-subtle: rgba(168, 153, 132, 0.2);
--border-default: rgba(168, 153, 132, 0.4);
--text-primary: #ebdbb2;
--text-secondary: #a89984;
--text-tertiary: #928374;
--accent-blue: #83a598;
--accent-green: #b8bb26;
}
/* Dracula Theme */
[data-theme="dracula"] {
--bg-primary: #282a36;
--bg-secondary: #44475a;
--bg-tertiary: #44475a;
--bg-elevated: #6272a4;
--border-subtle: rgba(189, 147, 249, 0.2);
--border-default: rgba(98, 114, 164, 0.5);
--text-primary: #f8f8f2;
--text-secondary: #6272a4;
--text-tertiary: #6272a4;
--accent-blue: #8be9fd;
--accent-green: #50fa7b;
}
/* Monokai Theme */
[data-theme="monokai"] {
--bg-primary: #272822;
--bg-secondary: #3e3d32;
--bg-tertiary: #3e3d32;
--bg-elevated: #49483e;
--border-subtle: rgba(255, 255, 255, 0.1);
--border-default: rgba(255, 255, 255, 0.2);
--text-primary: #f8f8f2;
--text-secondary: #75715e;
--text-tertiary: #75715e;
--accent-blue: #66d9ef;
--accent-green: #a6e22e;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
background: var(--bg-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color: var(--text-primary);
overflow: hidden;
}
/* Header */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 100;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 28px;
height: 28px;
background: var(--bg-tertiary);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
}
.logo-icon svg {
width: 16px;
height: 16px;
stroke: var(--accent-blue);
fill: none;
}
.logo-text {
font-weight: 600;
font-size: 15px;
letter-spacing: -0.3px;
}
.logo-text span {
color: var(--text-secondary);
font-weight: 400;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-tertiary);
border-radius: 20px;
border: 1px solid var(--border-subtle);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-tertiary);
transition: all 0.3s ease;
}
.status-dot.connected {
background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
}
.status-dot.connecting {
background: var(--accent-amber);
animation: pulse 1.5s infinite;
}
.status-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Main Layout */
.main-container {
display: flex;
height: 100vh;
padding-top: 48px;
}
/* Canvas Section */
.canvas-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--bg-primary);
position: relative;
}
.canvas-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
#viewer-canvas {
max-width: calc(100vw - 380px - 80px);
max-height: calc(100vh - 48px - 48px);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg), 0 0 0 1px var(--border-accent);
background: var(--bg-secondary);
transition: box-shadow 0.3s ease;
}
#viewer-canvas.active {
box-shadow: var(--shadow-lg), 0 0 0 2px var(--accent-blue), 0 0 40px rgba(99, 102, 241, 0.15);
}
/* Resize handle */
.resize-handle {
position: absolute;
bottom: -4px;
right: -4px;
width: 20px;
height: 20px;
cursor: nwse-resize;
z-index: 10;
}
.resize-handle::before {
content: '';
position: absolute;
bottom: 6px;
right: 6px;
width: 8px;
height: 8px;
border-right: 2px solid var(--accent-blue);
border-bottom: 2px solid var(--accent-blue);
opacity: 0.6;
}
.resize-handle:hover::before {
opacity: 1;
}
.canvas-placeholder {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: var(--text-tertiary);
}
.canvas-placeholder.hidden {
display: none;
}
.placeholder-icon {
width: 64px;
height: 64px;
stroke: var(--text-tertiary);
opacity: 0.5;
}
.placeholder-text {
font-size: 14px;
text-align: center;
max-width: 200px;
line-height: 1.5;
}
/* Inspector Sidebar */
.inspector-sidebar {
width: 340px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-default);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-tertiary);
}
.sidebar-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-title svg {
width: 14px;
height: 14px;
stroke: var(--accent-cyan);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.sidebar-content::-webkit-scrollbar {
width: 6px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: var(--border-default);
border-radius: 3px;
}
/* Info Sections */
.info-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
margin-bottom: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: var(--bg-elevated);
cursor: pointer;
transition: background 0.2s ease;
position: relative;
/* For chevron positioning if needed */
}
/* Chevron Icon */
.section-header::after {
content: '';
width: 16px;
height: 16px;
margin-left: auto;
/* Push to right */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
transition: transform 0.2s ease;
opacity: 0.7;
}
/* Collapsed State */
.info-section.collapsed .section-header::after {
transform: rotate(-90deg);
}
.info-section.collapsed .section-body {
display: none;
}
.section-header:hover {
background: rgba(255, 255, 255, 0.05);
}
.section-header:hover::after {
opacity: 1;
stroke: var(--text-primary);
}
.section-header:hover {
background: rgba(255, 255, 255, 0.05);
}
.section-icon {
width: 18px;
height: 18px;
stroke: var(--accent-blue);
flex-shrink: 0;
}
.section-title {
font-size: 13px;
font-weight: 600;
flex: 1;
}
.section-badge {
font-size: 11px;
font-weight: 500;
padding: 3px 8px;
background: rgba(99, 102, 241, 0.15);
color: var(--accent-blue);
border-radius: 4px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-body {
padding: 12px 14px;
border-top: 1px solid var(--border-subtle);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 8px 0;
gap: 12px;
}
.info-row:not(:last-child) {
border-bottom: 1px solid var(--border-subtle);
}
.info-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
flex-shrink: 0;
}
.info-value {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.08);
padding: 4px 8px;
border-radius: 4px;
text-align: right;
word-break: break-word;
max-width: 180px;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
color: var(--text-tertiary);
}
.empty-state svg {
width: 32px;
height: 32px;
stroke: var(--text-tertiary);
opacity: 0.5;
margin-bottom: 12px;
}
.empty-state-text {
font-size: 13px;
line-height: 1.5;
}
/* Footer Stats */
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border-default);
background: var(--bg-tertiary);
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-tertiary);
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
.stat-value {
color: var(--text-secondary);
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);
color: var(--text-secondary);
padding: 4px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
margin-right: 12px;
width: 28px;
height: 28px;
}
.icon-button:hover {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border-default);
}
.icon-button svg {
width: 16px;
height: 16px;
}
/* Fullscreen Mode */
:root:fullscreen .header,
:root:fullscreen .inspector-sidebar {
display: none !important;
}
:root:fullscreen .main-container {
padding-top: 0;
height: 100vh;
}
:root:fullscreen .canvas-section {
padding: 0;
width: 100vw;
height: 100vh;
background: #000;
}
/* Wrapper always fills available space to allow centering */
.canvas-wrapper {
width: 100%;
height: 100%;
}
/* Inner wrapper to anchor resize handle tightly to canvas */
.canvas-inner {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* Force inner to fill in fit mode OR fullscreen */
:root:fullscreen .canvas-inner,
.canvas-wrapper.fit-active .canvas-inner {
width: 100%;
height: 100%;
}
/* Fit Mode */
#viewer-canvas.fit-mode {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
border-radius: 0;
object-fit: cover;
box-shadow: none;
}
/* Remove padding when fitting */
.canvas-section.fit-mode-section {
padding: 0;
}
/* Hide resize handle in fit mode OR fullscreen */
:root:fullscreen .resize-handle,
.canvas-wrapper.fit-active .resize-handle {
display: none;
}
:root:fullscreen #viewer-canvas {
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
border-radius: 0 !important;
box-shadow: none !important;
object-fit: cover;
}
/* Clickable row styles */
.info-row.clickable-row {
cursor: pointer;
transition: background 0.2s ease;
}
.info-row.clickable-row:hover {
background: var(--bg-elevated);
}
.dropdown-indicator {
margin-left: 4px;
opacity: 0.6;
font-size: 10px;
}
/* Node selector dropdown */
.node-selector-dropdown {
position: absolute;
z-index: 100000;
max-height: 300px;
overflow-y: auto;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 200px;
max-width: 300px;
}
.node-selector-dropdown .dropdown-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.node-selector-dropdown .dropdown-item:hover {
background: var(--bg-tertiary);
}
.node-selector-dropdown .dropdown-item:last-child {
border-bottom: none;
}
.node-selector-dropdown .node-order {
color: var(--accent-cyan);
font-weight: 600;
min-width: 24px;
}
.node-selector-dropdown .node-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-selector-dropdown .node-type {
color: var(--text-tertiary);
font-size: 11px;
}
</style>
</head>
<body>
<header class="header">
<div class="header-left">
<div class="logo">
<div class="logo-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 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>
</div>
<span class="logo-text">MagnifyGlass <span>Viewer</span></span>
</div>
</div>
<div class="header-right">
<button id="fit-btn" class="icon-button" title="Fit to Container">
<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">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
</button>
<button id="fullscreen-btn" class="icon-button" title="Toggle Fullscreen">
<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">
<path
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>
</div>
</div>
</header>
<main class="main-container">
<section class="canvas-section">
<div class="canvas-wrapper">
<div class="canvas-inner">
<canvas id="viewer-canvas" width="300" height="300"></canvas>
<div class="resize-handle" id="resize-handle" title="Drag to resize"></div>
</div>
<div class="canvas-placeholder">
<svg class="placeholder-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke-width="1.5" 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>
<p class="placeholder-text">Waiting for connection from ComfyUI...</p>
</div>
</div>
</section>
<aside class="inspector-sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Inspector
</div>
</div>
<div class="sidebar-content" id="sidebar-content">
<div class="empty-state" id="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<p class="empty-state-text">Move your cursor over nodes in ComfyUI to see details here</p>
</div>
</div>
<!-- Stats footer removed per user request -->
</aside>
</main>
<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 statusLabel = document.querySelector('.status-label');
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;
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.
});
// Fullscreen Logic
const fullscreenBtn = document.getElementById('fullscreen-btn');
const enterFullscreenIcon = '<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"><path 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>';
const exitFullscreenIcon = '<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"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>';
fullscreenBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
});
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
fullscreenBtn.innerHTML = exitFullscreenIcon;
fullscreenBtn.title = "Exit Fullscreen";
} else {
fullscreenBtn.innerHTML = enterFullscreenIcon;
fullscreenBtn.title = "Enter Fullscreen";
}
});
channel.onmessage = (event) => {
const message = event.data;
switch (message.type) {
case 'frame':
handleFrame(message.data);
break;
case 'config':
handleConfig(message.data);
break;
case 'info':
handleInfo(message.data);
break;
case 'ping':
channel.postMessage({ type: 'pong', timestamp: Date.now() });
markConnected();
break;
case 'close':
markDisconnected();
break;
case 'nodes-list':
handleNodesList(message.data);
break;
}
};
function handleFrame(dataUrl) {
if (!dataUrl) return;
frameImage.onload = () => {
if (canvas.width !== frameImage.width || canvas.height !== frameImage.height) {
canvas.width = frameImage.width;
canvas.height = frameImage.height;
// Resolution display removed
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(frameImage, 0, 0);
canvas.classList.add('active');
};
frameImage.src = dataUrl;
lastFrameTime = Date.now();
markConnected();
}
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 handleInfo(info) {
if (!info) {
showEmptyState();
return;
}
hideEmptyState();
renderInfo(info);
}
function renderInfo(info) {
let html = '';
// Cursor section (Now First)
if (info.cursor && info.canvas) {
// Check collapsed state
const isCollapsed = window.collapsedSections?.has('cursor');
html += `
<div class="info-section ${isCollapsed ? 'collapsed' : ''}" data-section-id="cursor">
<div class="section-header" onclick="toggleSection(this)">
<svg class="section-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="6"/>
<circle cx="12" cy="12" r="2"/>
<line x1="12" y1="2" x2="12" y2="4"/>
<line x1="12" y1="20" x2="12" y2="22"/>
<line x1="2" y1="12" x2="4" y2="12"/>
<line x1="20" y1="12" x2="22" y2="12"/>
</svg>
<span class="section-title">Cursor</span>
</div>
<div class="section-body">
<div class="info-row">
<span class="info-label">Canvas X</span>
<span class="info-value">${Math.round(info.cursor.canvas.x)}</span>
</div>
<div class="info-row">
<span class="info-label">Canvas Y</span>
<span class="info-value">${Math.round(info.cursor.canvas.y)}</span>
</div>
</div>
</div>
`;
}
// Node section (Now Second)
if (info.hoveredNode) {
// Check collapsed state
const isCollapsed = window.collapsedSections?.has('node');
html += `
<div class="info-section ${isCollapsed ? 'collapsed' : ''}" data-section-id="node">
<div class="section-header" onclick="toggleSection(this)">
<svg class="section-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
<span class="section-title">Node</span>
<span class="section-badge">${escapeHtml(info.hoveredNode.type || 'Unknown')}</span>
</div>
<div class="section-body">
<div class="info-row clickable-row" onclick="requestNodeDropdown('title', this)">
<span class="info-label">Title</span>
<span class="info-value">${escapeHtml(info.hoveredNode.title || 'Untitled')}<span class="dropdown-indicator">▼</span></span>
</div>
${info.hoveredNode.id !== undefined ? `
<div class="info-row clickable-row" onclick="requestNodeDropdown('id', this)">
<span class="info-label">ID</span>
<span class="info-value">#${info.hoveredNode.id}<span class="dropdown-indicator">▼</span></span>
</div>` : ''}
${info.hoveredNode.executionOrder !== undefined ? `
<div class="info-row clickable-row" onclick="requestNodeDropdown('execOrder', this)">
<span class="info-label">Exec Order</span>
<span class="info-value">${info.hoveredNode.executionOrder}<span class="dropdown-indicator">▼</span></span>
</div>` : ''}
${info.hoveredNode.category ? `
<div class="info-row">
<span class="info-label">Category</span>
<span class="info-value">${escapeHtml(info.hoveredNode.category)}</span>
</div>` : ''}
${info.hoveredNode.pythonModule ? `
<div class="info-row">
<span class="info-label">Path</span>
<span class="info-value">${escapeHtml(info.hoveredNode.pythonModule)}</span>
</div>` : ''}
<!-- Extended Node Properties -->
<div class="info-row">
<span class="info-label">ID</span>
<span class="info-value">#${info.hoveredNode.id || '?'}</span>
</div>
${info.hoveredNode.mode !== undefined ? `
<div class="info-row">
<span class="info-label">Mode</span>
<span class="info-value">${getModeText(info.hoveredNode.mode)}</span>
</div>` : ''}
${info.hoveredNode.size ? `
<div class="info-row">
<span class="info-label">Size</span>
<span class="info-value">${formatSize(info.hoveredNode.size)}</span>
</div>` : ''}
${info.hoveredNode.pos ? `
<div class="info-row">
<span class="info-label">Pos</span>
<span class="info-value">${formatPos(info.hoveredNode.pos)}</span>
</div>` : ''}
<!-- Dynamic Lists (Inputs/Outputs/Widgets) -->
${renderList('Inputs', info.hoveredNode.inputs)}
${renderList('Outputs', info.hoveredNode.outputs)}
${renderList('Widgets', info.hoveredNode.widgets)}
</div>
</div>
`;
}
// Media section
if (info.media) {
html += `
<div class="info-section">
<div class="section-header">
<svg class="section-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<span class="section-title">Media</span>
<span class="section-badge">${info.media.tagName || 'Unknown'}</span>
</div>
<div class="section-body">
${info.media.naturalSize ? `
<div class="info-row">
<span class="info-label">Size</span>
<span class="info-value">${info.media.naturalSize}</span>
</div>` : ''}
</div>
</div>
`;
}
if (html) {
sidebarContent.innerHTML = html;
} else {
showEmptyState();
}
}
// Initialize collapsed state tracking
window.collapsedSections = new Set();
function toggleSection(header) {
const section = header.parentElement;
const sectionId = section.dataset.sectionId;
section.classList.toggle('collapsed');
if (section.classList.contains('collapsed')) {
window.collapsedSections.add(sectionId);
} else {
window.collapsedSections.delete(sectionId);
}
}
function getModeText(mode) {
// Check if it's already text
if (typeof mode === 'string') return mode;
// Map ComfyUI mode numbers
const modes = { 0: 'Always', 1: 'On Event', 2: 'Never', 3: 'Trigger' };
return modes[mode] || String(mode);
}
function formatSize(size) {
if (typeof size === 'object') return `${Math.round(size.w)}×${Math.round(size.h)}`;
return String(size);
}
function formatPos(pos) {
if (typeof pos === 'object') return `${Math.round(pos.x)}, ${Math.round(pos.y)}`;
return String(pos);
}
function renderList(title, items) {
if (!items || items.length === 0) return '';
const listHtml = items.map(item => {
const name = escapeHtml(item.name || item.label || 'Unknown');
const type = item.type ? `<span style="opacity:0.6; font-size:10px; margin-left:4px">(${escapeHtml(item.type)})</span>` : '';
const value = item.value !== undefined ? `<span style="opacity:0.8; margin-left:auto">${escapeHtml(String(item.value))}</span>` : '';
return `<div style="display:flex; justify-content:space-between; padding:2px 0; font-size:11px; color:var(--text-secondary);">
<span>- ${name}${type}</span>
${value}
</div>`;
}).join('');
return `
<div class="info-row" style="display:block; border-top:1px solid var(--border-subtle); margin-top:8px; padding-top:8px;">
<div style="font-weight:600; font-size:11px; margin-bottom:4px; color:var(--text-primary); opacity:0.8;">${title} (${items.length})</div>
<div style="padding-left:4px;">${listHtml}</div>
</div>
`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showEmptyState() {
sidebarContent.innerHTML = '';
sidebarContent.appendChild(emptyState.cloneNode(true));
}
function hideEmptyState() {
const existing = sidebarContent.querySelector('.empty-state');
if (existing) existing.remove();
}
// Node selection dropdown functionality
let currentDropdownAnchorRect = null;
let pendingDropdownType = null;
function requestNodeDropdown(type, anchorElement) {
console.log('[Popout] requestNodeDropdown called:', type, anchorElement);
// Store anchor position immediately (before potential re-render)
if (anchorElement) {
currentDropdownAnchorRect = anchorElement.getBoundingClientRect();
console.log('[Popout] Stored anchorRect:', currentDropdownAnchorRect);
}
pendingDropdownType = type;
// Request node list from main window
if (channel) {
console.log('[Popout] Sending request-nodes message');
channel.postMessage({ type: 'request-nodes', data: type });
} else {
console.log('[Popout] ERROR: channel is null!');
}
}
function handleNodesList(data) {
console.log('[Popout] handleNodesList called:', data);
console.log('[Popout] currentDropdownAnchorRect:', currentDropdownAnchorRect);
if (!data || !data.nodes || !currentDropdownAnchorRect) {
console.log('[Popout] handleNodesList returning early');
return;
}
showDropdown(data.nodes, data.sortBy);
}
function showDropdown(nodes, sortBy) {
// Save anchorRect before hideDropdown clears it
const savedAnchorRect = currentDropdownAnchorRect;
hideDropdown();
if (nodes.length === 0) return;
if (!savedAnchorRect) return;
const dropdown = document.createElement('div');
dropdown.className = 'node-selector-dropdown';
dropdown.id = 'node-dropdown';
nodes.forEach(node => {
const item = document.createElement('div');
item.className = 'dropdown-item';
if (sortBy === 'execOrder' && node.order !== undefined) {
item.innerHTML = `
<span class="node-order">#${node.order}</span>
<span class="node-title">${escapeHtml(node.title)}</span>
<span class="node-type">${escapeHtml(node.type)}</span>
`;
} else if (sortBy === 'id') {
item.innerHTML = `
<span class="node-order">#${node.id}</span>
<span class="node-title">${escapeHtml(node.title)}</span>
<span class="node-type">${escapeHtml(node.type)}</span>
`;
} else {
item.innerHTML = `
<span class="node-title">${escapeHtml(node.title)}</span>
<span class="node-type">${escapeHtml(node.type)}</span>
`;
}
item.addEventListener('click', (e) => {
e.stopPropagation();
selectNode(node.id);
hideDropdown();
});
dropdown.appendChild(item);
});
// Position dropdown
document.body.appendChild(dropdown);
console.log('[Popout] showDropdown: Created dropdown with', nodes.length, 'nodes');
const anchorRect = savedAnchorRect;
dropdown.style.top = `${anchorRect.bottom + 4}px`;
dropdown.style.left = `${anchorRect.left}px`;
console.log('[Popout] showDropdown: Positioned at', dropdown.style.top, dropdown.style.left);
// Adjust if off-screen
const dropdownRect = dropdown.getBoundingClientRect();
if (dropdownRect.right > window.innerWidth - 10) {
dropdown.style.left = `${window.innerWidth - dropdownRect.width - 10}px`;
}
if (dropdownRect.bottom > window.innerHeight - 10) {
dropdown.style.top = `${anchorRect.top - dropdownRect.height - 4}px`;
}
// Close on click outside
setTimeout(() => {
document.addEventListener('click', closeDropdownOnClickOutside);
}, 10);
}
function closeDropdownOnClickOutside(e) {
const dropdown = document.getElementById('node-dropdown');
if (dropdown && !dropdown.contains(e.target)) {
hideDropdown();
}
}
function hideDropdown() {
const dropdown = document.getElementById('node-dropdown');
if (dropdown) {
dropdown.remove();
}
document.removeEventListener('click', closeDropdownOnClickOutside);
currentDropdownAnchorRect = null;
pendingDropdownType = null;
}
function selectNode(nodeId) {
console.log('[Popout] selectNode called with nodeId:', nodeId);
// Send node selection to main window
if (channel) {
console.log('[Popout] Sending node-select message');
channel.postMessage({ type: 'node-select', data: nodeId });
}
}
function markConnected() {
if (!isConnected) {
isConnected = true;
statusDot.classList.remove('connecting');
statusDot.classList.add('connected');
statusLabel.textContent = 'Connected';
placeholder.classList.add('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();
}
// Check connection status
setInterval(() => {
if (!keepAlive && isConnected && Date.now() - lastFrameTime > 3000) {
markDisconnected();
}
}, 1000);
// Initial ping
channel.postMessage({ type: 'pong', timestamp: Date.now() });
// Cleanup
window.addEventListener('beforeunload', () => {
channel.postMessage({ type: 'close' });
channel.close();
});
// Escape to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (document.fullscreenElement) {
return;
}
channel.postMessage({ type: 'close' });
window.close();
}
});
// Fit Mode Logic
const fitBtn = document.getElementById('fit-btn');
const canvasWrapper = document.querySelector('.canvas-wrapper');
let isFitMode = false;
// Icons
const fitIcon = '<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"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>';
const originalSizeIcon = '<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"><rect x="4" y="4" width="16" height="16" rx="2" /><path d="M15 3h6v6M9 21H3v-6"/></svg>'; // Approx icon for "Manual/Original"
// Load saved fit mode
const savedFitMode = localStorage.getItem('magnifyglass-viewer-fitmode');
if (savedFitMode === 'true') {
toggleFitMode(true);
}
fitBtn.addEventListener('click', () => {
toggleFitMode(!isFitMode);
});
function toggleFitMode(active) {
isFitMode = active;
localStorage.setItem('magnifyglass-viewer-fitmode', isFitMode);
const canvasSection = canvasWrapper.closest('.canvas-section');
if (isFitMode) {
canvas.classList.add('fit-mode');
canvasWrapper.classList.add('fit-active');
if (canvasSection) canvasSection.classList.add('fit-mode-section');
fitBtn.title = "Restore Manual Size";
fitBtn.classList.add('active'); // Optional styling
// We don't change icon, or maybe we do?
// Let's keep the "arrows out" to mean "currently fit"?
// Or "arrows out" means "Click to Fit"?
// Let's toggle icon for clarity.
// Actually, let's just highlight the button/change title.
fitBtn.style.color = 'var(--text-primary)';
fitBtn.style.background = 'var(--bg-elevated)';
} else {
canvas.classList.remove('fit-mode');
canvasWrapper.classList.remove('fit-active');
if (canvasSection) canvasSection.classList.remove('fit-mode-section');
fitBtn.title = "Fit to Container";
fitBtn.style.color = '';
fitBtn.style.background = '';
}
}
// Resize functionality
const resizeHandle = document.getElementById('resize-handle');
// canvasWrapper already selected above
let isResizing = false;
let startX, startY, startWidth, startHeight;
// Load saved size
const savedSize = localStorage.getItem('magnifyglass-viewer-size');
if (savedSize) {
try {
const { width, height } = JSON.parse(savedSize);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
} catch (e) { }
}
resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = canvas.offsetWidth;
startHeight = canvas.offsetHeight;
document.body.style.cursor = 'nwse-resize';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// Keep aspect ratio by using the larger delta
const delta = Math.max(deltaX, deltaY);
const aspectRatio = startWidth / startHeight;
let newWidth = Math.max(200, startWidth + delta);
let newHeight = newWidth / aspectRatio;
// Constrain to viewport
const maxW = window.innerWidth - 400;
const maxH = window.innerHeight - 100;
if (newWidth > maxW) {
newWidth = maxW;
newHeight = newWidth / aspectRatio;
}
if (newHeight > maxH) {
newHeight = maxH;
newWidth = newHeight * aspectRatio;
}
canvas.style.width = `${newWidth}px`;
canvas.style.height = `${newHeight}px`;
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
// Save size
localStorage.setItem('magnifyglass-viewer-size', JSON.stringify({
width: canvas.offsetWidth,
height: canvas.offsetHeight
}));
}
});
</script>
</body>
</html>