Files
AEmotionStudio-ComfyUI-Magn…/web/popout-viewer.html
T
Æmotion StudioandClaude Opus 5.5 76bb34da1d Make the pop-out pair with its own tab and keep its connection
Each ComfyUI tab talks to its viewer on a session channel. Pings run only while
connected, status follows pings, and a hidden glass keeps feeding the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:43:59 -08:00

1899 lines
75 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>
<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);
}
* {
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);
}
/* The last frame, while no new ones come */
#viewer-canvas.dimmed {
opacity: 0.4;
}
/* 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;
}
.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;
}
/* Widget Editor Styles for Pop-out */
.widget-value-editable {
cursor: pointer;
border: 1px solid transparent;
transition: border-color 0.15s ease;
}
.widget-value-editable:hover {
border-color: var(--accent-blue);
}
.widget-card {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
}
.widget-card:hover {
background: var(--bg-tertiary);
border-color: var(--border-default);
}
.widget-button:hover {
transform: translateY(-1px);
}
.widget-button[data-kind="action"] {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.widget-button[data-kind="action"]:hover {
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25);
}
.widget-image img {
max-width: 100%;
}
.widget-editor-inline {
display: flex;
align-items: center;
gap: 4px;
}
.widget-editor-input {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: var(--bg-primary);
border: 1px solid var(--accent-blue);
border-radius: 4px;
color: var(--text-primary);
padding: 4px 8px;
width: 100%;
outline: none;
}
textarea.widget-editor-input {
resize: vertical;
line-height: 1.4;
}
.widget-editor-input:focus {
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.25);
}
.widget-editor-select {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: var(--bg-primary);
border: 1px solid var(--accent-blue);
border-radius: 4px;
color: var(--text-primary);
padding: 4px 8px;
width: 100%;
cursor: pointer;
}
</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>
<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';
// Each ComfyUI tab has its own channel, named by the session in this page's URL
const session = new URLSearchParams(location.search).get('session');
// DOM Elements
const canvas = document.getElementById('viewer-canvas');
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');
// State: 'connecting', 'connected', 'inactive' (no ping for a while) or 'closed' (ComfyUI closed or reloaded, or closed this viewer)
let connection = 'connecting';
let lastPingTime = 0;
let config = { glass: 'on', inspector: true };
const frameImage = new Image();
const channel = new BroadcastChannel(`${CHANNEL_NAME}:${session}`);
// 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':
lastPingTime = Date.now();
channel.postMessage({ type: 'pong' });
setConnection('connected');
break;
case 'close':
setConnection('closed');
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;
placeholder.classList.add('hidden');
}
function handleConfig(data) {
document.documentElement.setAttribute('data-theme', data.theme);
const inspectorChanged = data.inspector !== config.inspector;
config = data;
renderStatus();
if (inspectorChanged) handleInfo(currentInfo);
}
// The latest info, and the node the sidebar was last rendered from
let currentInfo = null;
let renderedNode = null;
function handleInfo(info) {
currentInfo = info;
// Re-rendering would drop an open editor; closing it renders the latest info
if (sidebarContent.querySelector('.widget-editor-inline')) return;
if (!info || !config.inspector) {
showEmptyState();
return;
}
hideEmptyState();
renderInfo(info);
}
/**
* Send widget edit to main window via BroadcastChannel
*/
function sendWidgetEdit(nodeId, widgetName, value) {
channel.postMessage({
type: 'widget-edit',
data: {
nodeId: nodeId,
widgetName: widgetName,
value: value
}
});
}
/**
* Send button click to main window via BroadcastChannel
* This invokes the widget's callback function on the main canvas
*/
function sendButtonClick(nodeId, widgetName) {
channel.postMessage({
type: 'button-click',
data: {
nodeId: nodeId,
widgetName: widgetName
}
});
}
// Widget rows carry their index into renderedNode.widgets; names never go into inline handlers
function widgetFor(element) {
const index = element.dataset.widget;
return renderedNode && index !== undefined ? renderedNode.widgets[Number(index)] : null;
}
sidebarContent.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]');
if (action) {
if (action.dataset.action === 'toggle-section') toggleSection(action);
else if (action.dataset.action === 'node-dropdown') requestNodeDropdown(action.dataset.sort, action);
else if (action.dataset.action === 'zoom-node' && renderedNode) requestZoomToNode(renderedNode.id);
return;
}
const element = e.target.closest('[data-widget]');
const widget = element && widgetFor(element);
if (!widget) return;
const kind = element.dataset.kind;
if (kind === 'toggle') {
toggleButtonWidget(element, renderedNode.id, widget.name);
} else if (kind === 'action') {
sendButtonClick(renderedNode.id, widget.name);
element.style.transform = 'scale(0.95)';
setTimeout(() => element.style.transform = '', 100);
} else if (kind === 'value') {
editWidgetValue(element, renderedNode.id, widget);
}
});
sidebarContent.addEventListener('input', (e) => {
const slider = e.target.closest('.widget-editor-slider');
if (slider) slider.nextElementSibling.textContent = slider.value;
});
sidebarContent.addEventListener('change', (e) => {
const slider = e.target.closest('.widget-editor-slider');
const widget = slider && widgetFor(slider);
if (widget) sendWidgetEdit(renderedNode.id, widget.name, parseFloat(slider.value));
});
function toggleButtonWidget(button, nodeId, widgetName) {
const newState = button.dataset.state !== 'true';
button.dataset.state = String(newState);
sendWidgetEdit(nodeId, widgetName, newState);
button.textContent = newState ? '✓ ON' : '○ OFF';
button.style.background = newState ? 'var(--accent-green, #22c55e)' : 'var(--bg-tertiary, #333)';
button.style.color = newState ? 'var(--bg-primary, #1a1a1a)' : 'var(--text-secondary, #aaa)';
button.style.borderColor = newState ? 'var(--accent-green, #22c55e)' : 'var(--border-default, #555)';
button.style.transform = 'scale(0.95)';
setTimeout(() => button.style.transform = '', 100);
}
// The real step: ComfyUI keeps a 10x legacy value in step and the actual one in step2
function stepFor(opts) {
if (opts.step2 !== undefined) return opts.step2;
return opts.step !== undefined ? opts.step / 10 : 'any';
}
/**
* Start editing a widget value inline. Enter or blur commits, Escape cancels;
* a textarea commits on Ctrl/Cmd+Enter so its newlines survive.
*/
function editWidgetValue(element, nodeId, widget) {
if (element.querySelector('.widget-editor-inline')) return;
const options = widget.options;
const opts = options && !Array.isArray(options) ? options : {};
const type = (widget.type || 'text').toLowerCase();
const currentValue = widget.value;
if (type === 'toggle' || type === 'boolean') {
// Read the CURRENT displayed value from the element text, not the stale parameter
const newValue = element.textContent.trim().toLowerCase() !== 'true';
sendWidgetEdit(nodeId, widget.name, newValue);
element.textContent = newValue ? 'true' : 'false';
element.style.color = newValue ? 'var(--accent-green)' : 'var(--text-tertiary)';
return;
}
let editor;
let readValue;
if (Array.isArray(options) && options.length > 0) {
editor = document.createElement('select');
editor.className = 'widget-editor-select';
// A hidden placeholder, so picking the current value again still fires change
for (const label of ['Select...', ...options]) {
const option = document.createElement('option');
option.textContent = String(label);
editor.appendChild(option);
}
editor.options[0].disabled = true;
editor.options[0].hidden = true;
editor.selectedIndex = 0;
// The option itself, so numeric combo values stay numbers
readValue = () => editor.selectedIndex > 0 ? options[editor.selectedIndex - 1] : undefined;
} else if (type === 'number' || type === 'knob' || type === 'int' || type === 'float') {
editor = document.createElement('input');
editor.type = 'number';
editor.className = 'widget-editor-input';
if (opts.min !== undefined) editor.min = String(opts.min);
if (opts.max !== undefined) editor.max = String(opts.max);
editor.step = String(stepFor(opts));
editor.value = String(currentValue);
readValue = () => editor.value === '' || isNaN(parseFloat(editor.value)) ? undefined : parseFloat(editor.value);
} else {
const text = String(currentValue ?? '');
const multiline = type === 'customtext' || opts.multiline || text.includes('\n');
editor = document.createElement(multiline ? 'textarea' : 'input');
editor.className = 'widget-editor-input';
if (multiline) {
editor.rows = Math.min(Math.max(text.split('\n').length, 3), 12);
} else {
editor.type = 'text';
}
editor.value = text;
readValue = () => editor.value;
}
let done = false;
const finish = (commit) => {
if (done) return;
done = true;
const value = readValue();
if (commit && value !== undefined) {
sendWidgetEdit(nodeId, widget.name, value);
element.innerHTML = '';
const shown = document.createElement('span');
shown.style.color = 'var(--accent-blue)';
shown.textContent = String(value);
element.appendChild(shown);
} else {
element.innerHTML = '';
handleInfo(currentInfo);
}
};
if (editor.tagName === 'SELECT') {
editor.addEventListener('change', () => finish(true));
// Clicking away without a pick cancels
editor.addEventListener('blur', () => finish(editor.selectedIndex > 0));
} else {
editor.addEventListener('blur', () => finish(true));
}
editor.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
finish(false);
} else if (e.key === 'Enter' && (editor.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
e.preventDefault();
finish(true);
}
});
const wrapper = document.createElement('div');
wrapper.className = 'widget-editor-inline';
wrapper.appendChild(editor);
element.innerHTML = '';
element.appendChild(wrapper);
editor.focus();
if (editor.select) editor.select();
}
function renderInfo(info) {
let html = '';
renderedNode = info.hoveredNode || null;
// 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" data-action="toggle-section">
<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" data-action="toggle-section">
<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" data-action="node-dropdown" data-sort="title">
<span class="info-label">Title</span>
<span class="info-value clickable-value">${escapeHtml(info.hoveredNode.title || 'Untitled')} (#${escapeHtml(info.hoveredNode.id)})<span class="dropdown-indicator">▼</span></span>
</div>
${info.hoveredNode.executionOrder !== undefined ? `
<div class="info-row clickable-row" data-action="node-dropdown" data-sort="execOrder">
<span class="info-label">Exec Order</span>
<span class="info-value">${escapeHtml(info.hoveredNode.executionOrder)}<span class="dropdown-indicator">▼</span></span>
</div>` : ''}
<div class="info-row clickable-row" data-action="zoom-node">
<span class="info-label">Location</span>
<span class="info-value"><span style="display: inline-flex; align-items: center; gap: 6px; vertical-align: middle;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg> Focus Node</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 -->
${info.hoveredNode.mode !== undefined ? `
<div class="info-row">
<span class="info-label">Mode</span>
<span class="info-value">${escapeHtml(String(info.hoveredNode.mode))}</span>
</div>` : ''}
${info.hoveredNode.size ? `
<div class="info-row">
<span class="info-label">Size</span>
<span class="info-value">${escapeHtml(formatSize(info.hoveredNode.size))}</span>
</div>` : ''}
${info.hoveredNode.pos ? `
<div class="info-row">
<span class="info-label">Pos</span>
<span class="info-value">${escapeHtml(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, info.hoveredNode.id)}
</div>
</div>
`;
}
if (html) {
sidebarContent.innerHTML = html;
for (const holder of sidebarContent.querySelectorAll('.widget-image')) {
const img = document.createElement('img');
img.src = holder.dataset.src;
holder.appendChild(img);
}
} 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 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, nodeId = null) {
if (!items || items.length === 0) return '';
// Icon mapping for different list types
const icons = {
'Inputs': `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>`,
'Outputs': `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`,
'Widgets': `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`
};
const icon = icons[title] || '';
// Gradient colors for different types
const gradients = {
'Inputs': 'linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(59, 130, 246, 0.05))',
'Outputs': 'linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(34, 197, 94, 0.05))',
'Widgets': 'linear-gradient(135deg, rgba(168, 85, 247, 0.15), rgba(168, 85, 247, 0.05))'
};
const gradient = gradients[title] || 'linear-gradient(135deg, rgba(100, 100, 100, 0.15), rgba(100, 100, 100, 0.05))';
// Badge colors for different types
const badgeColors = {
'Inputs': '#3b82f6',
'Outputs': '#22c55e',
'Widgets': '#a855f7'
};
const badgeColor = badgeColors[title] || '#888';
// Check if this is the Widgets section and we have a nodeId for editing
const isEditable = title === 'Widgets' && nodeId != null;
const listHtml = items.map((item, index) => {
const name = escapeHtml(item.name || item.label || 'Unknown');
const type = item.type ? escapeHtml(item.type) : null;
const isObjectValue = typeof item.value === 'object' && item.value !== null;
const rawValue = item.value === undefined ? null : isObjectValue ? JSON.stringify(item.value) : String(item.value);
const itemId = `${title.toLowerCase()}-${index}`;
// Detect toggle-style "button" widgets (have boolean values)
// These are buttons that toggle on/off state
const isButtonType = type && type.toLowerCase() === 'button';
const hasBooleanValue = rawValue === 'true' || rawValue === 'false';
const isToggleButton = isButtonType && hasBooleanValue;
// True action buttons are any button that doesn't have a boolean value
// (button callbacks may set any value, so we can't rely on value being null)
const isActionButton = isButtonType && !hasBooleanValue;
// Check for slider (widgets explicitly typed as 'slider')
const isSlider = type && type.toLowerCase() === 'slider';
// Plot nodes send an <img> with a data URI; only the URI itself is used
const imageSrc = rawValue && rawValue.match(IMAGE_DATA_URI)?.[0];
const value = rawValue ? escapeHtml(rawValue) : null;
// For toggle-style button widgets, render a toggle button with on/off visual indication
if (isToggleButton && nodeId != null) {
const isOn = rawValue === 'true';
const toggleColor = isOn ? 'var(--accent-green, #22c55e)' : 'var(--text-tertiary, #666)';
const toggleBg = isOn ? 'var(--accent-green, #22c55e)' : 'var(--bg-tertiary, #333)';
const toggleText = isOn ? 'ON' : 'OFF';
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">TOGGLE</span>
</div>
<button
id="${itemId}"
class="widget-button"
data-widget="${index}"
data-kind="toggle"
data-state="${isOn}"
style="
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background: ${toggleBg};
color: ${isOn ? 'var(--bg-primary, #1a1a1a)' : 'var(--text-secondary, #aaa)'};
border: 2px solid ${isOn ? 'var(--accent-green, #22c55e)' : 'var(--border-default, #555)'};
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
"
>${isOn ? '✓ ON' : '○ OFF'}</button>
</div>`;
}
// For true action button widgets, render a clickable styled button
if (isActionButton && nodeId != null) {
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">ACTION</span>
</div>
<button
class="widget-button"
data-widget="${index}"
data-kind="action"
style="
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background: var(--accent-purple, #a855f7);
color: var(--bg-primary, #1a1a1a);
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
"
>▶ Click</button>
</div>`;
}
// For slider widgets, render inline range control
if (isSlider && nodeId != null) {
const opts = item.options && typeof item.options === 'object' ? item.options : {};
const min = opts.min !== undefined ? opts.min : 0;
const max = opts.max !== undefined ? opts.max : 100;
const step = opts.step2 !== undefined || opts.step !== undefined ? stepFor(opts) : 0.01;
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
${type ? `<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">${type}</span>` : ''}
</div>
<div class="widget-slider-container" style="display: flex; align-items: center; gap: 8px; width: 100%;">
<input type="range"
class="widget-editor-slider"
min="${escapeHtml(min)}"
max="${escapeHtml(max)}"
step="${escapeHtml(step)}"
value="${value}"
style="flex: 1; accent-color: var(--accent-purple); cursor: pointer;"
data-widget="${index}"
/>
<span class="widget-slider-value" style="
min-width: 50px;
text-align: right;
font-family: monospace;
color: var(--accent-blue);
font-weight: 600;
">${value}</span>
</div>
</div>`;
}
if (imageSrc) {
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
${type ? `<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">${type}</span>` : ''}
</div>
<div style="
background: var(--bg-tertiary);
border-radius: 4px;
padding: 8px;
text-align: center;
overflow: hidden;
" class="widget-image" data-src="${imageSrc}"></div>
</div>`;
}
// Object values have no editor that keeps them intact
const canEditValue = isEditable && rawValue != null && !isObjectValue;
const editableClass = canEditValue ? 'widget-value-editable' : '';
const editableClick = canEditValue ? `data-widget="${index}" data-kind="value"` : '';
const editableStyle = canEditValue ? 'cursor: pointer;' : '';
const editableTitle = canEditValue ? 'title="Click to edit"' : '';
return `
<div class="widget-card" style="
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 6px;
transition: all 0.15s ease;
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: ${value ? '6px' : '0'};">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
${type ? `<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.2px;
word-break: break-all;
max-width: 100%;
">${type}</span>` : ''}
${canEditValue ? `<span style="
font-size: 9px;
color: var(--accent-amber);
margin-left: auto;
opacity: 0.7;
">✏️ editable</span>` : ''}
</div>
${value ? `<div style="position: relative;">
<div id="${itemId}" class="${editableClass}" ${editableClick} ${editableTitle} style="
font-size: 11px;
color: var(--accent-blue);
font-weight: 500;
background: var(--bg-tertiary);
padding: 8px 10px;
border-radius: 4px;
word-break: break-word;
white-space: pre-wrap;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
line-height: 1.4;
${editableStyle}
">${value}</div>
</div>` : ''}
</div>`;
}).join('');
return `
<div style="
margin-top: 12px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-default);
background: var(--bg-primary);
">
<div style="
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: ${gradient};
border-bottom: 1px solid var(--border-subtle);
">
<span style="color: ${badgeColor}; display: flex; align-items: center;">${icon}</span>
<span style="
font-weight: 600;
font-size: 12px;
color: var(--text-primary);
">${title}</span>
<span style="
font-size: 10px;
padding: 2px 8px;
background: ${badgeColor}33;
color: ${badgeColor};
border-radius: 10px;
font-weight: 600;
margin-left: auto;
">${items.length}</span>
</div>
<div style="padding: 8px;">
${listHtml}
</div>
</div>
`;
}
const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const IMAGE_DATA_URI = /data:image\/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+\/=]+/;
function escapeHtml(text) {
return String(text ?? '').replace(/[&<>"']/g, c => HTML_ESCAPES[c]);
}
function showEmptyState() {
const empty = emptyState.cloneNode(true);
if (!config.inspector) empty.querySelector('.empty-state-text').textContent = 'The Inspector is off in ComfyUI';
sidebarContent.innerHTML = '';
sidebarContent.appendChild(empty);
}
function hideEmptyState() {
const existing = sidebarContent.querySelector('.empty-state');
if (existing) existing.remove();
}
// Node selection dropdown functionality
let currentDropdownAnchorRect = null;
function requestNodeDropdown(type, anchorElement) {
// Store anchor position immediately (before potential re-render)
if (anchorElement) {
currentDropdownAnchorRect = anchorElement.getBoundingClientRect();
}
if (channel) {
channel.postMessage({ type: 'request-nodes', data: type });
}
}
function requestZoomToNode(nodeId) {
if (channel) {
channel.postMessage({ type: 'zoom-node', data: nodeId });
}
}
function handleNodesList(data) {
if (!data || !data.nodes || !currentDropdownAnchorRect) 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">#${escapeHtml(node.order)}</span>
<span class="node-title">${escapeHtml(node.title)} (#${escapeHtml(node.id)})</span>
<span class="node-type">${escapeHtml(node.type)}</span>
`;
} else if (sortBy === 'id') {
item.innerHTML = `
<span class="node-order">#${escapeHtml(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)} (#${escapeHtml(node.id)})</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);
const anchorRect = savedAnchorRect;
dropdown.style.top = `${anchorRect.bottom + 4}px`;
dropdown.style.left = `${anchorRect.left}px`;
// 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;
}
function selectNode(nodeId) {
// Send node selection to main window
if (channel) {
channel.postMessage({ type: 'node-select', data: nodeId });
}
}
const STATUS_LABELS = { connecting: 'Connecting...', inactive: 'ComfyUI tab inactive', closed: 'Disconnected from ComfyUI' };
const GLASS_LABELS = { on: 'Connected', hidden: 'Glass hidden', off: 'Glass off' };
function setConnection(state) {
if (state === connection) return;
connection = state;
renderStatus();
// The ComfyUI tab skips frames while this page is hidden
if (state === 'connected') channel.postMessage({ type: 'visibility', data: document.hidden });
}
// Status never touches the sidebar, so an open editor and the last info stay
function renderStatus() {
const live = connection === 'connected';
statusDot.classList.toggle('connected', live && config.glass !== 'off');
statusDot.classList.toggle('connecting', connection === 'connecting' || connection === 'inactive');
statusLabel.textContent = live ? GLASS_LABELS[config.glass] : STATUS_LABELS[connection];
canvas.classList.toggle('dimmed', !live || config.glass === 'off');
}
document.addEventListener('visibilitychange', () => {
if (connection === 'connected') channel.postMessage({ type: 'visibility', data: document.hidden });
});
// ComfyUI pings only while it counts this page as connected; until then announce once a second
setInterval(() => {
if (connection === 'connected' && Date.now() - lastPingTime > 3000) setConnection('inactive');
if (connection !== 'connected') channel.postMessage({ type: 'pong' });
}, 1000);
channel.postMessage({ type: 'pong' });
window.addEventListener('beforeunload', () => {
channel.postMessage({ type: 'close' });
channel.close();
});
// Escape closes the node list; an editor handles its own
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideDropdown();
});
// 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>