Files
AEmotionStudio-ComfyUI-Magn…/web/popout-viewer.html
T
Æmotion StudioandClaude Opus 5.5 ebae4f904d Show pop-out run badges only in the workflow tab that ran
Run ids name other nodes in another workflow tab. The run state now says
whether the tab on screen is the one that ran, and is sent again with the
info a tab switch changes.

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

2513 lines
98 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>
/* The Inspector's tokens (css/info-panel.css). ComfyUI sends its palette values in the config. */
:root,
.inspector-sidebar {
--mg-fg: var(--fg-color, #fff);
--mg-muted: color-mix(in srgb, var(--mg-fg) 72%, transparent);
--mg-surface: var(--comfy-menu-bg, #171718);
--mg-stroke: var(--interface-stroke, var(--border-color, #4e4e4e));
--mg-control: var(--secondary-background, var(--comfy-input-bg, #262729));
--mg-control-hover: var(--secondary-background-hover, var(--mg-stroke));
--mg-accent: var(--mg-panel-accent, var(--primary-background, #0b8ce9));
/* The accent as text: the light palette's accent is too pale on its own */
--mg-accent-fg: color-mix(in srgb, var(--mg-accent) 45%, var(--mg-fg));
--mg-ring: var(--mg-fg);
--mg-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
--mg-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
:root {
--mg-backdrop: var(--comfy-menu-secondary-bg, #0f0f0f);
--mg-error: var(--error-text, #ff4444);
/* The error colour as text, readable on light palettes too */
--mg-error-fg: color-mix(in srgb, var(--mg-error) 70%, var(--mg-fg));
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
}
/* The Inspector's text colour setting applies to its rows, as in ComfyUI */
.inspector-sidebar {
--mg-fg: var(--mg-panel-fg, var(--fg-color, #fff));
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Header, error banner, frame and Inspector, run bar; a hidden banner leaves its row empty */
body {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--mg-surface);
color: var(--mg-fg);
font-size: 0.8125rem;
line-height: 1.35;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
svg {
flex: none;
width: 1rem;
height: 1rem;
pointer-events: none;
}
:focus-visible {
outline: 2px solid var(--mg-ring);
outline-offset: 2px;
}
/* Header */
.header {
grid-row: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.25rem 0.75rem;
min-height: 2.75rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
border-bottom: 1px solid var(--mg-stroke);
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
font-size: 0.9375rem;
font-weight: 600;
white-space: nowrap;
}
.logo-text span {
color: var(--mg-muted);
font-weight: 400;
}
.header-right {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: 0.5rem;
color: var(--mg-muted);
font-size: 0.75rem;
white-space: nowrap;
}
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--error-text, #ff4444);
}
.status-dot.connected {
background: var(--mg-accent-fg);
}
.status-dot.connecting {
background: var(--mg-muted);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
50% {
opacity: 0.4;
}
}
.icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: var(--mg-muted);
transition: background-color 120ms var(--mg-ease), color 120ms var(--mg-ease);
}
.icon-button:hover {
background: var(--mg-control-hover);
color: var(--mg-fg);
}
.icon-button[aria-pressed="true"] {
background: color-mix(in srgb, var(--mg-accent) 18%, transparent);
color: var(--mg-accent-fg);
}
.icon-button:disabled {
background: transparent;
color: var(--mg-muted);
opacity: 0.5;
cursor: default;
}
/* The glass zoom, written to ComfyUI's Zoom Level setting */
.zoom-control {
display: flex;
align-items: center;
}
.zoom-value {
min-width: 3rem;
color: var(--mg-muted);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
text-align: center;
}
.view-modes {
display: flex;
padding: 0.125rem;
border: 1px solid var(--mg-stroke);
border-radius: 0.5rem;
}
.view-modes button {
min-height: 1.625rem;
padding: 0 0.625rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: var(--mg-muted);
font-size: 0.75rem;
}
.view-modes button:hover {
background: var(--mg-control-hover);
color: var(--mg-fg);
}
.view-modes button[aria-pressed="true"] {
background: var(--mg-control);
color: var(--mg-fg);
font-weight: 600;
}
/* Frame and Inspector side by side; the Inspector stacks under the frame in narrow windows */
/* The divider sets --inspector-w; the frame keeps at least 12rem */
.main-container {
grid-row: 3;
display: grid;
grid-template-columns: minmax(0, 1fr) min(var(--inspector-w, 20rem), calc(100% - 12rem));
min-height: 0;
}
.canvas-section {
position: relative;
min-width: 0;
min-height: 0;
background: var(--mg-backdrop);
}
/* The area the frame may fill, as a size container for the frame's limits */
.canvas-wrapper {
position: absolute;
inset: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
container-type: size;
}
/* Anchors the resize handle to the frame's corner */
.canvas-inner {
position: relative;
display: flex;
}
#viewer-canvas {
display: block;
/* The resize handle sets --canvas-size, kept inside the area */
width: var(--canvas-size, auto);
aspect-ratio: 1;
max-width: 100cqw;
max-height: 100cqh;
object-fit: contain;
border: 1px solid var(--mg-stroke);
border-radius: 0.5rem;
background: var(--mg-surface);
transition: opacity 200ms var(--mg-ease);
}
/* The last frame, while no new ones come */
#viewer-canvas.dimmed {
opacity: 0.4;
}
.resize-handle {
position: absolute;
right: -0.375rem;
bottom: -0.375rem;
width: 1.25rem;
height: 1.25rem;
cursor: nwse-resize;
touch-action: none;
}
.resize-handle::before {
content: '';
position: absolute;
right: 0.375rem;
bottom: 0.375rem;
width: 0.5rem;
height: 0.5rem;
border-right: 2px solid var(--mg-muted);
border-bottom: 2px solid var(--mg-muted);
}
.resize-handle:hover::before {
border-color: var(--mg-fg);
}
.canvas-placeholder {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
max-width: 14rem;
color: var(--descrip-text, var(--mg-muted));
text-align: center;
}
.canvas-placeholder svg {
width: 3rem;
height: 3rem;
}
.canvas-placeholder.hidden {
display: none;
}
/* Fit: the frame fills the area */
.canvas-wrapper.fit-active {
inset: 0;
}
.canvas-wrapper.fit-active #viewer-canvas,
:root:fullscreen #viewer-canvas {
width: min(100cqw, 100cqh);
height: auto;
border-radius: 0;
}
.canvas-wrapper.fit-active .resize-handle,
:root:fullscreen .resize-handle {
display: none;
}
:root:fullscreen :is(.header, .error-banner, .run-bar, .inspector-sidebar) {
display: none;
}
:root:fullscreen .main-container {
grid-template-columns: minmax(0, 1fr);
}
:root:fullscreen .canvas-section {
background: #000;
}
:root:fullscreen .canvas-wrapper {
inset: 0;
}
:root:fullscreen #viewer-canvas {
border: 0;
}
/* Output: the frame area shows one of the shown node's outputs */
.output-view {
display: none;
color: var(--descrip-text, var(--mg-muted));
}
.main-container[data-view="output"] .output-view {
display: flex;
}
.main-container[data-view="output"] :is(.canvas-inner, .canvas-placeholder) {
display: none;
}
.output-image {
max-width: 100cqw;
max-height: 100cqh;
object-fit: contain;
border: 1px solid var(--mg-stroke);
border-radius: 0.5rem;
}
:root:fullscreen .output-image {
border: 0;
border-radius: 0;
}
/* Inspector */
.inspector-sidebar {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--mg-stroke);
background: var(--mg-surface);
color: var(--mg-fg);
}
.inspector-divider {
position: absolute;
top: 0;
bottom: 0;
left: -0.3125rem;
z-index: 1;
width: 0.5rem;
cursor: col-resize;
touch-action: none;
}
.inspector-divider:focus-visible {
outline: none;
}
.inspector-divider:is(:hover, :focus-visible, .dragging)::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: calc(50% - 1px);
width: 2px;
background: var(--mg-accent);
}
/* The node's identity, then the navigation row and the breadcrumb, as in the Inspector */
.node-header {
border-bottom: 1px solid var(--mg-stroke);
}
.node-identity {
display: flex;
align-items: baseline;
gap: 0.375em;
width: 100%;
min-height: 2.75em;
padding: 0.5em 0.75em;
border: 0;
background: transparent;
text-align: left;
}
.node-identity:disabled {
cursor: default;
}
.node-identity:not(:disabled):hover {
background: var(--mg-control);
}
.node-name,
.node-badge,
.goto-btn span,
.crumb,
.crumb-group {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.node-name {
font-size: 0.9375rem;
font-weight: 700;
}
.node-name.is-empty {
color: var(--mg-muted);
font-weight: 600;
}
.node-id {
flex: none;
color: var(--mg-muted);
font-variant-numeric: tabular-nums;
}
.node-badge {
flex: 0 4 auto;
min-width: 3.5em;
max-width: 40%;
padding: 0.125em 0.5em;
border-radius: 1em;
background: var(--mg-control);
color: var(--mg-muted);
font-size: 0.75rem;
}
/* Running x/y, Cached, Done or Error, from the run ComfyUI sends */
.run-badge {
flex: none;
padding: 0.125em 0.5em;
border: 1px solid var(--mg-stroke);
border-radius: 1em;
color: var(--mg-muted);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.run-badge[data-state="running"] {
border-color: var(--mg-accent);
color: var(--mg-accent-fg);
}
.run-badge[data-state="error"] {
border-color: var(--mg-error);
color: var(--mg-error-fg);
}
.copy-icon {
display: flex;
align-self: center;
margin-left: auto;
color: var(--mg-muted);
opacity: 0;
}
.copy-icon svg {
width: 0.75rem;
height: 0.75rem;
}
.node-identity:not(:disabled):is(:hover, :focus-visible) .copy-icon,
.copied .copy-icon {
opacity: 1;
}
.copied .copy-icon {
color: var(--mg-accent-fg);
}
.node-nav {
position: relative;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
border-bottom: 1px solid var(--mg-stroke);
}
.goto-btn {
display: flex;
flex: 1;
align-items: center;
gap: 0.375em;
min-width: 0;
height: 2rem;
margin-left: 0.25rem;
padding: 0 0.5em;
border: 1px solid var(--mg-stroke);
border-radius: 0.375rem;
background: var(--mg-control);
color: var(--mg-muted);
}
.goto-btn:not(:disabled):hover,
.goto-btn[aria-expanded="true"] {
background: var(--mg-control-hover);
color: var(--mg-fg);
}
.goto-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* Go to node: under the navigation row, as wide as the Inspector */
.node-picker {
position: absolute;
top: 100%;
right: 0.375rem;
left: 0.375rem;
z-index: 100;
display: flex;
flex-direction: column;
max-height: min(20rem, 60vh);
margin-top: 0.25rem;
border: 1px solid var(--mg-stroke);
border-radius: 0.5rem;
background: var(--mg-surface);
box-shadow: var(--mg-shadow);
}
.node-picker[hidden] {
display: none;
}
.node-picker-filter {
margin: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--mg-stroke);
border-radius: 0.375rem;
background: var(--mg-control);
}
.node-picker-filter:focus-visible {
outline: none;
border-color: var(--mg-accent);
}
.node-picker-list {
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--mg-stroke) transparent;
}
.node-picker-empty {
padding: 0.5rem 0.75rem;
color: var(--mg-muted);
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
cursor: pointer;
}
.dropdown-item.active {
background: var(--mg-control-hover);
}
.dropdown-item[aria-selected="true"] {
font-weight: 600;
box-shadow: inset 2px 0 var(--mg-accent);
}
.dropdown-item-order {
min-width: 1.5rem;
color: var(--mg-accent-fg);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.dropdown-item-title {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.dropdown-item-type,
.dropdown-item-id {
flex: none;
color: var(--mg-muted);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.dropdown-item-type {
flex-shrink: 1;
max-width: 40%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.node-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25em 0.375em;
padding: 0.25rem 0.75rem;
border-bottom: 1px solid var(--mg-stroke);
color: var(--mg-muted);
font-size: 0.75rem;
}
.node-breadcrumb[hidden] {
display: none;
}
.crumb {
max-width: 10em;
}
.crumb[aria-current] {
color: var(--mg-fg);
}
.crumb-group {
max-width: 12em;
padding: 0 0.375em;
border: 1px solid var(--mg-stroke);
border-radius: 0.25rem;
}
.crumb-action {
margin-left: auto;
padding: 0.125em 0.5em;
border: 0;
border-radius: 0.375rem;
background: var(--mg-control);
}
.crumb-action:hover {
background: var(--mg-control-hover);
}
.node-outputs {
display: flex;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--mg-stroke);
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--mg-stroke) transparent;
}
.node-outputs[hidden] {
display: none;
}
.output-thumb {
flex: none;
width: 3.5rem;
height: 3.5rem;
padding: 0;
overflow: hidden;
border: 1px solid var(--mg-stroke);
border-radius: 0.375rem;
background: var(--mg-backdrop);
}
.output-thumb:hover {
border-color: var(--mg-muted);
}
.output-thumb[aria-pressed="true"] {
border: 2px solid var(--mg-accent);
}
.output-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.sidebar-content {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--mg-stroke) transparent;
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.25rem;
padding: 0 0.75rem;
border: 0;
border-bottom: 1px solid var(--mg-stroke);
background: transparent;
text-align: left;
user-select: none;
}
.section-header:hover {
background: var(--mg-control-hover);
}
.section-title {
flex: 1;
font-weight: 600;
}
.section-chevron {
display: flex;
color: var(--mg-muted);
transition: transform 120ms var(--mg-ease);
}
.info-section.collapsed .section-chevron {
transform: rotate(-90deg);
}
.info-section.collapsed .section-body {
display: none;
}
/* Rows: label and value side by side, as in the Inspector */
/* Label, value and its copy button */
.info-row {
display: grid;
grid-template-columns: minmax(4.5em, 2fr) minmax(0, 3fr) auto;
align-items: baseline;
column-gap: 0.75em;
min-height: 2em;
padding: 0.375em 0.75em;
}
/* A switch has no text baseline */
.info-row:has(.switch) {
align-items: center;
}
.info-row + .info-row {
border-top: 1px solid color-mix(in srgb, var(--mg-stroke) 60%, transparent);
}
/* Buttons, images and text being edited take the whole row */
.info-row.is-wide,
.info-row:has(textarea) {
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25em;
}
.info-row:has(.widget-editor-inline) :is(.copy-btn, .value-expand) {
display: none;
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
align-self: start;
width: 1.5rem;
height: 1.5rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: var(--mg-muted);
opacity: 0;
}
.copy-btn .copy-icon {
margin: 0;
opacity: 1;
}
.copy-btn:hover {
background: var(--mg-control-hover);
color: var(--mg-fg);
}
.info-row:hover .copy-btn,
.copy-btn:focus-visible,
.copy-btn.copied {
opacity: 1;
}
@media (hover: none) {
.copy-btn {
opacity: 1;
}
}
/* Off screen while the clipboard fallback copies from it */
.copy-fallback {
position: fixed;
top: 0;
left: -9999px;
opacity: 0;
}
/* Long values show a few lines until expanded */
.is-clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
}
.info-row.is-expanded .is-clamped {
display: block;
}
.value-expand {
grid-area: 2 / 2;
justify-self: start;
border: 0;
background: transparent;
color: var(--mg-accent-fg);
font-size: 0.75rem;
}
.value-expand:hover {
text-decoration: underline;
}
/* The last queued prompt failed validation on this input */
.info-row.has-error .info-label {
color: var(--mg-error-fg);
}
.row-error {
grid-column: 1 / -1;
color: var(--mg-error-fg);
font-size: 0.75rem;
overflow-wrap: anywhere;
}
.link-chips {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
min-width: 0;
}
.link-chip {
max-width: 100%;
padding: 0 0.375em;
overflow: hidden;
border: 1px solid var(--mg-stroke);
border-radius: 0.25rem;
background: transparent;
white-space: nowrap;
text-overflow: ellipsis;
}
span.link-chip {
color: var(--mg-muted);
}
button.link-chip:hover {
background: var(--mg-control-hover);
}
.edit-hint {
margin: 0.375em 0.75em;
padding: 0.125em 0.5em;
border: 1px solid var(--mg-stroke);
border-radius: 0.25rem;
background: transparent;
color: var(--mg-muted);
font-size: 0.75rem;
}
.edit-hint:hover {
background: var(--mg-control-hover);
color: var(--mg-fg);
}
.info-label {
min-width: 0;
overflow-wrap: break-word;
color: var(--mg-muted);
}
.widget-type {
display: inline-block;
margin-left: 0.375em;
font-size: 0.6875rem;
}
.linked-tag {
margin-left: 0.25em;
padding: 0 0.25em;
border: 1px solid var(--mg-stroke);
border-radius: 0.25rem;
font-size: 0.6875rem;
}
.info-value,
.widget-value {
min-width: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
font-variant-numeric: tabular-nums;
}
.widget-value-editable:hover {
background: var(--mg-control);
}
.widget-value-editable {
margin: -0.125em -0.375em;
padding: 0.125em 0.375em;
border-radius: 0.25rem;
cursor: text;
}
.value-empty {
color: var(--mg-muted);
font-style: italic;
}
.widget-button {
min-height: 1.75rem;
padding: 0 0.75rem;
border: 1px solid var(--mg-stroke);
border-radius: 0.375rem;
background: var(--mg-control);
}
.widget-button:hover {
background: var(--mg-control-hover);
}
/* Toggles, as the sidebar draws them */
.switch {
position: relative;
width: 2.25em;
height: 1.25em;
border: 0;
border-radius: 1em;
background: var(--border-default, var(--mg-stroke));
transition: background-color 120ms var(--mg-ease);
}
.switch::before {
content: '';
position: absolute;
top: 0.125em;
left: 0.125em;
width: 1em;
height: 1em;
border-radius: 50%;
background: var(--base-background, var(--mg-surface));
transition: transform 120ms var(--mg-ease);
}
.switch[aria-checked="true"] {
background: var(--mg-accent);
}
.switch[aria-checked="true"]::before {
transform: translateX(1em);
}
.switch:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.widget-slider {
display: flex;
align-items: center;
gap: 0.5em;
}
.widget-editor-slider {
flex: 1;
min-width: 0;
accent-color: var(--mg-accent);
}
.widget-slider-value {
min-width: 3em;
text-align: right;
font-variant-numeric: tabular-nums;
}
.widget-image img {
display: block;
max-width: 100%;
border-radius: 0.25rem;
}
.widget-editor-input,
.widget-editor-select {
width: 100%;
min-width: 0;
padding: 0.25em 0.5em;
border: 1px solid var(--mg-stroke);
border-radius: 0.375rem;
background: var(--mg-control);
font-variant-numeric: tabular-nums;
}
:is(.widget-editor-input, .widget-editor-select):focus-visible {
outline: none;
border-color: var(--mg-accent);
}
.widget-editor-number {
display: flex;
align-items: center;
gap: 0.25em;
}
.widget-editor-number input::-webkit-outer-spin-button,
.widget-editor-number input::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.widget-editor-stepper {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 1.75em;
height: 1.75em;
border: 0;
border-radius: 0.375rem;
background: var(--mg-control);
color: var(--mg-muted);
}
.widget-editor-stepper:hover {
background: var(--mg-control-hover);
color: var(--mg-fg);
}
.widget-editor-stepper svg {
width: 0.875rem;
height: 0.875rem;
}
textarea.widget-editor-input {
resize: vertical;
font-family: ui-monospace, monospace;
}
/* The last prompt's error */
.error-banner {
grid-row: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.5rem;
padding: 0.375rem 0.5rem 0.375rem 0.75rem;
border-bottom: 1px solid var(--mg-error);
background: color-mix(in srgb, var(--mg-error) 12%, var(--mg-surface));
}
.error-banner[hidden] {
display: none;
}
.error-banner > svg {
color: var(--mg-error-fg);
}
.error-text {
flex: 1 1 12rem;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.error-node {
font-weight: 600;
}
.error-action {
display: flex;
align-items: center;
gap: 0.375em;
min-height: 1.75rem;
padding: 0 0.625rem;
border: 1px solid var(--mg-stroke);
border-radius: 0.375rem;
background: var(--mg-control);
font-size: 0.75rem;
}
.error-action:hover {
background: var(--mg-control-hover);
}
.error-action .copy-icon {
margin: 0;
opacity: 1;
}
/* The running node, its progress and the queue */
.run-bar {
grid-row: 4;
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 1.75rem;
padding: 0.25rem 0.75rem;
border-top: 1px solid var(--mg-stroke);
color: var(--mg-muted);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.run-node {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.run-node.is-running {
color: var(--mg-fg);
}
.run-progress {
flex: 1;
min-width: 4rem;
height: 0.25rem;
border-radius: 0.125rem;
background: var(--mg-control);
overflow: hidden;
}
.run-progress-fill {
width: 0;
height: 100%;
background: var(--mg-accent);
transition: width 120ms var(--mg-ease);
}
.run-queue {
flex: none;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2.5rem 1.25rem;
color: var(--mg-muted);
text-align: center;
}
.empty-state svg {
width: 2rem;
height: 2rem;
}
@media (max-width: 720px) {
.main-container {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(10rem, 55%) minmax(0, 1fr);
}
.inspector-sidebar {
border-top: 1px solid var(--mg-stroke);
border-left: 0;
}
.inspector-divider,
.logo-text {
display: none;
}
.canvas-wrapper {
inset: 0.75rem;
}
}
/* Glass or Info alone; Output is laid out like Split */
.main-container:not([data-view="split"], [data-view="output"]) {
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
}
.main-container[data-view="glass"] .inspector-sidebar,
.main-container[data-view="info"] .canvas-section {
display: none;
}
.main-container[data-view="info"] .inspector-sidebar {
border: 0;
}
.main-container:not([data-view="split"], [data-view="output"]) .inspector-divider {
display: none;
}
/* Fullscreen is for the frame, whatever the view */
:root:fullscreen .main-container .canvas-section {
display: block;
}
/* The system setting, and ComfyUI's Reduce Motion setting */
@media (prefers-reduced-motion: reduce) {
*,
*::before {
transition: none !important;
animation: none !important;
}
}
:root.reduce-motion *,
:root.reduce-motion *::before {
transition: none !important;
animation: none !important;
}
</style>
</head>
<body>
<header class="header">
<div class="logo">
<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 class="logo-text">MagnifyGlass <span>Viewer</span></span>
</div>
<div class="header-right">
<div class="status-indicator" role="status">
<span class="status-dot connecting"></span>
<span class="status-label">Connecting...</span>
</div>
<div class="zoom-control" role="group" aria-label="Glass zoom">
<button type="button" id="zoom-out" class="icon-button" title="Zoom out" aria-label="Zoom out" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<span class="zoom-value" title="Glass zoom; scroll over the frame to change it">–</span>
<button type="button" id="zoom-in" class="icon-button" title="Zoom in" aria-label="Zoom in" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="view-modes" role="group" aria-label="View">
<button type="button" data-view="split" title="Frame and Inspector" aria-pressed="true">Split</button>
<button type="button" data-view="glass" title="Frame only" aria-pressed="false">Glass</button>
<button type="button" data-view="info" title="Inspector only" aria-pressed="false">Info</button>
<button type="button" data-view="output" title="The shown node's output and the Inspector (Esc returns to the glass)" aria-pressed="false">Output</button>
</div>
<button id="fit-btn" class="icon-button" title="Fit to window" aria-label="Fit to window" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
</button>
<button id="fullscreen-btn" class="icon-button" title="Enter fullscreen" aria-label="Enter 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>
</header>
<div class="error-banner" role="alert" hidden>
<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="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span class="error-text"><span class="error-node"></span> <span class="error-message"></span></span>
<button type="button" class="error-action" data-action="go-to-error" title="Go to the node in ComfyUI, if its graph is on screen">Go to node</button>
<button type="button" class="error-action" data-action="copy-traceback" title="Copy the traceback and the message">
<span class="copy-icon"><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="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>
Copy traceback
</button>
</div>
<main class="main-container" data-view="split">
<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, double-click to reset"></div>
</div>
<div class="canvas-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
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>Waiting for connection from ComfyUI...</p>
</div>
<div class="output-view">
<img class="output-image" alt="Node output" hidden>
<p class="output-empty">No output for this node yet</p>
</div>
</div>
</section>
<aside class="inspector-sidebar" id="inspector">
<div class="inspector-divider" role="separator" aria-orientation="vertical" aria-label="Inspector width" aria-valuemin="240" tabindex="0"
title="Drag to resize, double-click to reset"></div>
<div class="node-header">
<button type="button" class="node-identity" data-action="copy-title" title="Copy title and id" aria-label="Copy title and id" disabled>
<span class="node-name is-empty">Inspector</span>
<span class="node-id" hidden></span>
<span class="node-badge" hidden></span>
<span class="run-badge" hidden></span>
<span class="copy-icon"><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="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>
</button>
</div>
<div class="node-nav" role="toolbar" aria-label="Node navigation">
<button type="button" class="icon-button" data-action="back" title="Back" aria-label="Back" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
</button>
<button type="button" class="icon-button" data-action="forward" title="Forward" aria-label="Forward" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</button>
<button type="button" class="icon-button" data-action="prev-node" title="Previous in execution order (←)" aria-label="Previous in execution order" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button type="button" class="icon-button" data-action="next-node" title="Next in execution order (→)" aria-label="Next in execution order" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<button type="button" class="icon-button" data-action="focus-node" title="Centre on node" aria-label="Centre on node" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button type="button" class="icon-button" data-action="lock-node" title="Lock to this node (L)" aria-label="Lock to this node" aria-pressed="false" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</button>
<button type="button" class="goto-btn" data-action="go-to-node" title="Go to node (Ctrl+K)" aria-haspopup="listbox" aria-expanded="false" disabled>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Go to…</span>
</button>
<div class="node-picker" hidden>
<input type="text" class="node-picker-filter" placeholder="Title, type or #id" role="combobox" aria-label="Go to node"
aria-autocomplete="list" aria-expanded="true" aria-controls="node-picker-list">
<div class="node-picker-list" id="node-picker-list" role="listbox" aria-label="Nodes"></div>
<div class="node-picker-empty" hidden>No matching nodes</div>
</div>
</div>
<div class="node-breadcrumb" hidden></div>
<div class="node-outputs" role="group" aria-label="Outputs" hidden></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="currentColor"
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>
</aside>
</main>
<footer class="run-bar" aria-label="Queue">
<span class="run-node">Idle</span>
<div class="run-progress" role="progressbar" aria-label="Node progress" aria-valuemin="0" aria-valuenow="0"><div class="run-progress-fill"></div></div>
<span class="run-queue">Queue 0</span>
</footer>
<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 inspector = document.getElementById('inspector');
const sidebarContent = document.getElementById('sidebar-content');
const emptyState = document.getElementById('empty-state');
const identity = inspector.querySelector('.node-identity');
const breadcrumb = inspector.querySelector('.node-breadcrumb');
const outputStrip = inspector.querySelector('.node-outputs');
const outputImage = document.querySelector('.output-image');
const outputEmpty = document.querySelector('.output-empty');
const gotoBtn = inspector.querySelector('[data-action="go-to-node"]');
const picker = inspector.querySelector('.node-picker');
const pickerFilter = picker.querySelector('.node-picker-filter');
const pickerList = picker.querySelector('.node-picker-list');
const pickerEmpty = picker.querySelector('.node-picker-empty');
const mainContainer = document.querySelector('.main-container');
const canvasSection = document.querySelector('.canvas-section');
const divider = inspector.querySelector('.inspector-divider');
const zoomValue = document.querySelector('.zoom-value');
const zoomOut = document.getElementById('zoom-out');
const zoomIn = document.getElementById('zoom-in');
const viewButtons = document.querySelectorAll('.view-modes [data-view]');
const errorBanner = document.querySelector('.error-banner');
const runBar = document.querySelector('.run-bar');
const runProgress = runBar.querySelector('.run-progress');
// The view mode, the Inspector's width and the collapsed sections, kept per browser
const LAYOUT_KEY = 'magnifyglass-viewer-layout';
let layout = { view: 'split', inspectorWidth: null, collapsed: [] };
try {
layout = { ...layout, ...JSON.parse(readStorage(LAYOUT_KEY) || '{}') };
} catch {
// An unreadable saved layout: start from the defaults
}
// Blocked storage throws on access; the viewer then starts from its defaults
function readStorage(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function saveLayout() {
localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...layout, collapsed: [...collapsedSections] }));
}
// State: 'connecting', 'connected', 'inactive' (no ping for a while) or 'closed' (ComfyUI closed or reloaded, or closed this viewer)
let connection = 'connecting';
let lastPingTime = 0;
let config = { glass: 'on', inspector: true };
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', () => {
const full = !!document.fullscreenElement;
fullscreenBtn.innerHTML = full ? exitFullscreenIcon : enterFullscreenIcon;
fullscreenBtn.title = full ? 'Exit fullscreen' : 'Enter fullscreen';
fullscreenBtn.setAttribute('aria-label', fullscreenBtn.title);
// Info shows the frame in fullscreen
postVisibility();
});
channel.onmessage = (event) => {
const message = event.data;
switch (message.type) {
case 'config':
handleConfig(message.data);
break;
case 'info':
handleInfo(message.data);
break;
case 'run':
run = message.data;
renderRun();
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;
case 'options':
handleOptions(message.data);
break;
}
};
// ComfyUI draws each glass frame here as soon as it has rendered it
window.drawFrame = (source) => {
if (canvas.width !== source.width || canvas.height !== source.height) {
canvas.width = source.width;
canvas.height = source.height;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(source, 0, 0);
placeholder.classList.add('hidden');
};
function handleConfig(data) {
applyPalette(data.palette);
document.documentElement.classList.toggle('reduce-motion', data.reduceMotion);
config = data;
// A zoom still waiting to be written is newer than ComfyUI's
if (zoomTimer === null) zoom = data.zoom;
renderStatus();
// Renders only when the Inspector was turned on or off
handleInfo(currentInfo);
}
// ComfyUI's computed palette values feed the --mg-* tokens; an empty value drops an override that went away
function applyPalette(palette) {
const root = document.documentElement.style;
for (const [name, value] of Object.entries(palette.properties)) {
if (value) root.setProperty(name, value);
else root.removeProperty(name);
}
root.colorScheme = palette.scheme;
root.fontFamily = palette.fontFamily;
root.fontSize = palette.fontSize;
}
// The latest info, the info the Inspector was last rendered from and its key, and the combo editor waiting for its options
let currentInfo = null;
let renderedInfo = null;
let renderedKey = null;
let pendingOptions = null;
// The texts of the rendered copy buttons, the long values shown in full and the collapsed sections
let copyTexts = [];
const expandedValues = new Set();
const collapsedSections = new Set(layout.collapsed);
function handleInfo(info) {
currentInfo = info;
// Outputs sit outside the rows and update in place, so live previews never re-render the rows
renderOutputs(config.inspector ? info : null);
// Re-rendering would drop an open editor; closing it renders the latest info
if (sidebarContent.querySelector('.widget-editor-inline')) return;
// ComfyUI sends unchanged info again after edits; rendering it would drop hover, focus and text selection
const key = JSON.stringify([info && { ...info, outputs: undefined }, config.inspector]);
if (key === renderedKey) return;
renderedKey = key;
renderedInfo = config.inspector ? info : null;
renderHeader(renderedInfo);
if (renderedInfo?.node) {
renderInfo(renderedInfo);
} else {
showEmptyState();
}
}
// Render the latest info even when it looks unchanged, e.g. after an editor shows a value of its own
function rerender() {
renderedKey = null;
handleInfo(currentInfo);
}
function sendWidgetEdit(row, value) {
// The sent value shows until ComfyUI answers, so its answer renders even if unchanged
renderedKey = null;
channel.postMessage({ type: 'widget-edit', data: { nodeId: renderedInfo.node.id, widgetName: row.widgetName, value } });
}
// Rows carry their index into renderedInfo.rows; names never go into inline handlers
function rowFor(element) {
const index = element.dataset.widget;
return renderedInfo && index !== undefined ? renderedInfo.rows[Number(index)] : null;
}
// Navigation ComfyUI runs for the node shown here
const PANEL_ACTIONS = ['back', 'forward', 'prev-node', 'next-node', 'focus-node', 'lock-node', 'enter-subgraph'];
inspector.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]');
if (action) {
const name = action.dataset.action;
if (PANEL_ACTIONS.includes(name)) {
channel.postMessage({ type: 'panel-action', data: { name, nodeId: renderedInfo?.node?.id ?? null } });
} else if (name === 'go-to-node') {
if (picker.hidden) openPicker();
else closePicker(true);
} else if (name === 'copy-title') {
copyText(action, `${renderedInfo.node.title} #${renderedInfo.node.id}`);
} else if (name === 'copy') {
copyText(action, copyTexts[Number(action.dataset.copy)]);
} else if (name === 'expand-value') {
toggleExpanded(action);
} else if (name === 'toggle-section') {
toggleSection(action);
} else if (name === 'jump-to-link') {
channel.postMessage({ type: 'node-select', data: Number(action.dataset.nodeId) });
} else if (name === 'show-output') {
outputIndex = Number(action.dataset.output);
setView('output');
saveLayout();
}
return;
}
const element = e.target.closest('[data-widget]');
const row = element && rowFor(element);
if (!row) return;
if (element.dataset.kind === 'action') {
channel.postMessage({ type: 'button-click', data: { nodeId: renderedInfo.node.id, widgetName: row.widgetName } });
} else if (element.dataset.kind === 'value') {
editWidgetValue(element, row);
}
});
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 row = slider && rowFor(slider);
if (row) sendWidgetEdit(row, parseFloat(slider.value));
});
function handleOptions(data) {
const pending = pendingOptions;
if (!pending || pending.nodeId !== data.nodeId || pending.widgetName !== data.widgetName) return;
pendingOptions = null;
pending.fill(data.options);
}
// A number as the widget shows it: INT whole, FLOAT to its precision
function formatNumber(value, row) {
const precision = row.widgetType === 'INT' ? 0 : row.constraints.precision;
return precision === undefined ? String(value) : Number(value).toFixed(precision);
}
// One step of a number widget, as WidgetSyncManager.getStep: Shift is x10, Alt x0.1 but never finer than the widget stores
function stepSize(constraints, e) {
const step = constraints.step ?? 1;
if (e.shiftKey) return step * 10;
if (e.altKey) return Math.max(step / 10, constraints.round ?? 10 ** -(constraints.precision ?? 3));
return step;
}
/**
* Start editing a widget value inline. Enter or blur commits, Escape cancels;
* a textarea commits on Ctrl/Cmd+Enter so its newlines survive. Only a changed value is sent.
*/
function editWidgetValue(element, row) {
if (element.querySelector('.widget-editor-inline')) return;
const type = row.widgetType;
const constraints = row.constraints;
if (type === 'boolean') {
const newValue = !row.rawValue;
sendWidgetEdit(row, newValue);
element.setAttribute('aria-checked', String(newValue));
return;
}
const wrapper = document.createElement('div');
wrapper.className = 'widget-editor-inline';
let editor;
let readValue;
if (type === 'combo') {
editor = document.createElement('select');
editor.className = 'widget-editor-select';
const loading = document.createElement('option');
loading.textContent = 'Loading…';
loading.disabled = true;
editor.appendChild(loading);
let options = [];
// The option itself, so numeric combo values stay numbers
readValue = () => options[editor.selectedIndex];
// ComfyUI sends the list only for an open editor, as it can be long
pendingOptions = {
nodeId: renderedInfo.node.id,
widgetName: row.widgetName,
editor,
fill(list) {
if (!list.length) return finish(false);
options = list;
editor.replaceChildren(...list.map(value => {
const option = document.createElement('option');
option.textContent = String(value);
return option;
}));
// The list opens on the current value
editor.selectedIndex = list.indexOf(row.rawValue);
committed = readValue();
// The options arrive after the click, whose user activation may have run out; the list then opens on the next click
try {
editor.showPicker?.();
} catch {
}
}
};
channel.postMessage({ type: 'request-options', data: { nodeId: pendingOptions.nodeId, widgetName: row.widgetName } });
wrapper.appendChild(editor);
} else if (type === 'INT' || type === 'FLOAT') {
editor = document.createElement('input');
editor.type = 'number';
editor.className = 'widget-editor-input';
if (constraints.min !== undefined) editor.min = String(constraints.min);
if (constraints.max !== undefined) editor.max = String(constraints.max);
editor.step = String(constraints.step ?? (type === 'INT' ? 1 : 'any'));
editor.value = formatNumber(row.rawValue, row);
readValue = () => {
const value = parseFloat(editor.value);
return isNaN(value) ? undefined : type === 'INT' ? Math.round(value) : value;
};
// The steppers and arrow keys send each step at once, as in the Inspector
const nudge = (direction, e) => {
const { min = -Infinity, max = Infinity } = constraints;
const value = (readValue() ?? committed ?? 0) + direction * stepSize(constraints, e);
editor.value = formatNumber(Math.min(max, Math.max(min, value)), row);
commit();
};
editor.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
nudge(e.key === 'ArrowUp' ? 1 : -1, e);
});
wrapper.classList.add('widget-editor-number');
for (const direction of [-1, 1]) {
const stepper = document.createElement('button');
stepper.type = 'button';
stepper.className = 'widget-editor-stepper';
stepper.innerHTML = direction < 0 ? ICONS.minus : ICONS.plus;
stepper.setAttribute('aria-label', direction < 0 ? 'Decrease' : 'Increase');
// Keeps focus in the input, whose blur would close the editor
stepper.addEventListener('mousedown', (e) => e.preventDefault());
stepper.addEventListener('click', (e) => nudge(direction, e));
wrapper.appendChild(stepper);
}
wrapper.insertBefore(editor, wrapper.lastChild);
} else {
const text = String(row.rawValue ?? '');
const multiline = type === '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;
wrapper.appendChild(editor);
}
// The value the editor opened with, or the last one sent
let committed = readValue();
let done = false;
function commit() {
const value = readValue();
if (value === undefined || value === committed) return false;
committed = value;
sendWidgetEdit(row, value);
return true;
}
function finish(keep) {
if (done) return;
done = true;
if (pendingOptions?.editor === editor) pendingOptions = null;
wrapper.remove();
if (keep && commit()) {
// Shown until ComfyUI sends the value it kept
element.textContent = String(committed);
} else {
// The info that came while editing, which has any steps already sent
rerender();
}
}
editor.addEventListener('blur', () => finish(true));
if (editor.tagName === 'SELECT') {
editor.addEventListener('change', () => finish(true));
}
editor.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Not Output's Escape
e.stopPropagation();
finish(false);
} else if (e.key === 'Enter' && (editor.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
e.preventDefault();
finish(true);
}
});
element.innerHTML = '';
element.appendChild(wrapper);
editor.focus();
if (editor.select) editor.select();
}
// The node's identity, navigation and breadcrumb, updated in place so focus stays on its buttons
function renderHeader(info) {
const node = info?.node;
const nav = info?.nav;
const name = identity.querySelector('.node-name');
name.textContent = node ? node.title : 'Inspector';
name.title = node ? node.title : '';
name.classList.toggle('is-empty', !node);
const id = identity.querySelector('.node-id');
id.hidden = !node;
id.textContent = node ? `#${node.id}` : '';
const badge = identity.querySelector('.node-badge');
badge.textContent = node ? node.type : '';
badge.title = badge.textContent;
badge.hidden = !node || node.type === node.title;
identity.disabled = !node;
renderRunBadge();
const button = (action) => inspector.querySelector(`.node-nav [data-action="${action}"]`);
button('back').disabled = !nav?.canBack;
button('forward').disabled = !nav?.canForward;
button('prev-node').disabled = !nav;
button('next-node').disabled = !nav;
button('focus-node').disabled = !node;
const lock = button('lock-node');
lock.disabled = !node && !nav?.locked;
lock.setAttribute('aria-pressed', String(!!nav?.locked));
lock.title = nav?.locked ? 'Unlock: show the hovered node (L)' : 'Lock to this node (L)';
lock.setAttribute('aria-label', nav?.locked ? 'Unlock' : 'Lock to this node');
gotoBtn.disabled = !nav;
if (!nav) closePicker(false);
const path = node?.graphPath.length ? ['Root', ...node.graphPath] : [];
breadcrumb.innerHTML = [
path.map((crumb, i) => `<span class="crumb" title="${escapeHtml(crumb)}"${i === path.length - 1 ? ' aria-current="location"' : ''}>${escapeHtml(crumb)}</span>`).join('<span aria-hidden="true">›</span>'),
node?.group ? `<span class="crumb-group" title="Group">${escapeHtml(node.group)}</span>` : '',
node?.isSubgraph ? '<button type="button" class="crumb-action" data-action="enter-subgraph">Enter subgraph</button>' : ''
].join('');
breadcrumb.hidden = !breadcrumb.innerHTML;
}
// Output view: the shown outputs, the picked one, the node it belongs to, and the view Escape returns to
let outputUrls = [];
let outputIndex = 0;
let outputNodeId = null;
let glassView = 'split';
// Thumbnails of the shown node's outputs, updated in place so each keeps its image while a new URL loads
function renderOutputs(info) {
outputUrls = info?.outputs ?? [];
const nodeId = info?.node?.executionId ?? null;
if (nodeId !== outputNodeId) {
outputNodeId = nodeId;
outputIndex = 0;
}
while (outputStrip.children.length > outputUrls.length) outputStrip.lastElementChild.remove();
outputUrls.forEach((url, i) => {
let thumb = outputStrip.children[i];
if (!thumb) {
thumb = document.createElement('button');
thumb.type = 'button';
thumb.className = 'output-thumb';
thumb.dataset.action = 'show-output';
thumb.dataset.output = String(i);
thumb.title = 'Show in the frame area';
thumb.setAttribute('aria-label', `Output ${i + 1}`);
thumb.appendChild(document.createElement('img')).alt = '';
outputStrip.appendChild(thumb);
}
const img = thumb.firstElementChild;
if (img.getAttribute('src') !== url) img.src = url;
});
outputStrip.hidden = !outputUrls.length;
renderOutputView();
}
function renderOutputView() {
outputIndex = Math.min(outputIndex, Math.max(outputUrls.length - 1, 0));
const url = outputUrls[outputIndex];
outputImage.hidden = !url;
outputEmpty.hidden = !!url;
if (url && outputImage.getAttribute('src') !== url) outputImage.src = url;
[...outputStrip.children].forEach((thumb, i) => thumb.setAttribute('aria-pressed', String(layout.view === 'output' && i === outputIndex)));
}
function renderInfo(info) {
const node = info.node;
copyTexts = [];
sidebarContent.innerHTML = [
section('node', 'Node', `
${textRow('Mode', node.mode)}
${node.execOrder !== undefined ? textRow('Exec Order', String(node.execOrder)) : ''}
${renderRows(info)}
${!info.nav.canEdit && info.rows.some(row => row.isEditable) ? '<button type="button" class="edit-hint" data-action="lock-node" title="Lock to this node to edit its widgets">Lock to edit</button>' : ''}`),
info.links.inputs.length ? section('inputs', 'Inputs', renderSlots(info.links.inputs)) : '',
info.links.outputs.length ? section('outputs', 'Outputs', renderSlots(info.links.outputs)) : ''
].join('');
for (const holder of sidebarContent.querySelectorAll('.widget-image')) {
const img = document.createElement('img');
img.src = holder.dataset.src;
holder.appendChild(img);
}
}
function section(id, title, body) {
const collapsed = collapsedSections.has(id);
return `
<section class="info-section${collapsed ? ' collapsed' : ''}" data-section-id="${id}">
<button type="button" class="section-header" data-action="toggle-section" aria-expanded="${!collapsed}">
<span class="section-title">${title}</span>
<span class="section-chevron">${ICONS.chevronDown}</span>
</button>
<div class="section-body">${body}</div>
</section>`;
}
// A slot and a chip for each node at the other end of its links; a chip jumps to its node.
// A link to the subgraph's own input or output has no node to jump to.
function renderSlots(slots) {
return slots.map(slot => {
const chips = slot.linked.map(link => {
if (link.id === undefined) return `<span class="link-chip">${escapeHtml(`${link.title} · ${link.slot}`)}</span>`;
const text = `${link.title} #${link.id} · ${link.slot}`;
return `<button type="button" class="link-chip" data-action="jump-to-link" data-node-id="${escapeHtml(link.id)}" title="Go to ${escapeHtml(text)}">${escapeHtml(text)}</button>`;
}).join('');
const value = chips ? `<div class="link-chips">${chips}</div>` : '<span class="value-empty">not connected</span>';
return `
<div class="info-row${slot.missing ? ' has-error' : ''}">
<span class="info-label">${escapeHtml(slot.name)}<span class="widget-type">${escapeHtml(slot.type)}</span></span>
${value}
${slot.missing ? '<span class="row-error">Required, but nothing is connected</span>' : ''}
</div>`;
}).join('');
}
function toggleSection(header) {
const section = header.parentElement;
const collapsed = section.classList.toggle('collapsed');
header.setAttribute('aria-expanded', String(!collapsed));
if (collapsed) {
collapsedSections.add(section.dataset.sectionId);
} else {
collapsedSections.delete(section.dataset.sectionId);
}
saveLayout();
}
function toggleExpanded(button) {
const row = button.closest('.info-row');
const expanded = !expandedValues.has(row.dataset.expandKey);
if (expanded) {
expandedValues.add(row.dataset.expandKey);
} else {
expandedValues.delete(row.dataset.expandKey);
}
row.classList.toggle('is-expanded', expanded);
button.textContent = expanded ? 'Show less' : 'Show more';
button.setAttribute('aria-expanded', String(expanded));
}
// The text is kept in copyTexts, so long values are not repeated in the markup
function copyButton(text, label) {
copyTexts.push(text);
return `<button type="button" class="copy-btn" data-action="copy" data-copy="${copyTexts.length - 1}" title="Copy value" aria-label="Copy ${escapeHtml(label)}"><span class="copy-icon">${ICONS.copy}</span></button>`;
}
function textRow(label, value) {
return `<div class="info-row"><span class="info-label">${label}</span><span class="info-value">${escapeHtml(value)}</span>${copyButton(value, label)}</div>`;
}
/**
* Copy text, as the Inspector does. navigator.clipboard is missing on plain http, such as ComfyUI
* opened from another machine on the LAN, so fall back to a selected textarea there.
*/
async function copyText(button, text) {
try {
await navigator.clipboard.writeText(text);
} catch {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.className = 'copy-fallback';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
button.focus();
if (!copied) {
button.dataset.title ??= button.title;
button.title = 'Copy failed: the browser blocked access to the clipboard';
setTimeout(() => { button.title = button.dataset.title; }, 1500);
return;
}
}
const icon = button.querySelector('.copy-icon');
icon.innerHTML = ICONS.check;
button.classList.add('copied');
setTimeout(() => {
icon.innerHTML = ICONS.copy;
button.classList.remove('copied');
}, 1500);
}
// The widget rows as ComfyUI's Inspector computed them; it also decides which can be edited
function renderRows(info) {
return info.rows.map((row, index) => {
const label = escapeHtml(row.label);
// Values without a primitive raw value, like objects, have no editor that keeps them intact
const editable = row.isEditable && info.nav.canEdit && row.rawValue !== undefined;
const lockHint = row.isEditable && !info.nav.canEdit ? ' title="Lock to this node to edit"' : '';
// Plot nodes send an <img> with a data URI; only the URI itself is used
const imageSrc = row.value.match(IMAGE_DATA_URI)?.[0];
if (row.isActionable) {
return `<div class="info-row is-wide"><button type="button" class="widget-button" data-widget="${index}" data-kind="action">${label}</button></div>`;
}
let body;
let long = false;
if (imageSrc) {
body = `<div class="widget-image" data-src="${imageSrc}"></div>`;
} else if (row.widgetType === 'boolean' && typeof row.rawValue === 'boolean') {
const attrs = editable ? ` data-widget="${index}" data-kind="value"` : ` disabled${lockHint}`;
body = `<div class="widget-value"><button type="button" class="switch" role="switch" aria-checked="${row.rawValue}" aria-label="${label}"${attrs}></button></div>`;
} else if (editable && row.widgetType === 'slider') {
const { min = 0, max = 100, step = 0.01 } = row.constraints;
body = `
<div class="widget-slider">
<input type="range" class="widget-editor-slider" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}" value="${escapeHtml(row.rawValue)}" aria-label="${label}" data-widget="${index}">
<span class="widget-slider-value">${escapeHtml(row.value)}</span>
</div>`;
} else {
long = isLongText(row.value);
const shown = row.value === '' ? '<span class="value-empty">empty</span>' : escapeHtml(row.value);
const attrs = editable ? ` data-widget="${index}" data-kind="value" title="Click to edit"` : lockHint;
body = `<div class="widget-value${editable ? ' widget-value-editable' : ''}${long ? ' is-clamped' : ''}"${attrs}>${shown}</div>`;
}
const expandKey = `${info.node.id}:${row.label}`;
const expanded = long && expandedValues.has(expandKey);
return `
<div class="info-row${imageSrc ? ' is-wide' : ''}${expanded ? ' is-expanded' : ''}${row.error ? ' has-error' : ''}"${long ? ` data-expand-key="${escapeHtml(expandKey)}"` : ''}>
<span class="info-label">${label}${row.isLinked ? '<span class="linked-tag" title="Set by a link">linked</span>' : ''}${row.widgetType ? `<span class="widget-type">${escapeHtml(row.widgetType)}</span>` : ''}</span>
${body}
${imageSrc ? '' : copyButton(row.value, row.label)}
${long ? `<button type="button" class="value-expand" data-action="expand-value" aria-expanded="${expanded}">${expanded ? 'Show less' : 'Show more'}</button>` : ''}
${row.error ? `<span class="row-error">${escapeHtml(row.error)}</span>` : ''}
</div>`;
}).join('');
}
// Values this long are clamped to a few lines with Show more, as in the Inspector
function isLongText(text) {
return text.length > 200 || text.split('\n').length > 4;
}
// Paths from src/shared/icons.ts
const ICONS = {
chevronDown: '<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"><polyline points="6 9 12 15 18 9"/></svg>',
copy: '<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="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
check: '<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"><polyline points="20 6 9 17 4 12"/></svg>',
minus: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>',
plus: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
};
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);
}
// Go to node: ComfyUI searches its graph, the same search as the Inspector's picker
let pickerEntries = [];
let pickerActive = -1;
function openPicker() {
picker.hidden = false;
gotoBtn.setAttribute('aria-expanded', 'true');
pickerFilter.value = '';
pickerEntries = [];
pickerList.innerHTML = '';
pickerEmpty.hidden = true;
pickerFilter.focus();
searchNodes();
}
function closePicker(refocus) {
if (picker.hidden) return;
picker.hidden = true;
gotoBtn.setAttribute('aria-expanded', 'false');
if (refocus) gotoBtn.focus();
}
function searchNodes() {
channel.postMessage({ type: 'search-nodes', data: pickerFilter.value });
}
function handleNodesList({ query, nodes }) {
// A reply to an earlier query, or for a picker that has closed
if (picker.hidden || query !== pickerFilter.value) return;
const current = renderedInfo?.node?.id;
pickerEntries = nodes;
pickerList.innerHTML = nodes.map((node, i) => `
<div class="dropdown-item" id="node-picker-${i}" role="option" aria-selected="${node.id === current}" data-index="${i}">
<span class="dropdown-item-order">${node.order >= 0 ? escapeHtml(node.order) : ''}</span>
<span class="dropdown-item-title">${escapeHtml(node.title)}</span>
<span class="dropdown-item-type">${escapeHtml(node.type)}</span>
<span class="dropdown-item-id">#${escapeHtml(node.id)}</span>
</div>`).join('');
pickerEmpty.hidden = nodes.length > 0;
pickerActive = -1;
// Unfiltered, it opens on the shown node
setPickerActive(query ? 0 : nodes.findIndex(node => node.id === current));
}
function setPickerActive(index, scroll = true) {
pickerList.children[pickerActive]?.classList.remove('active');
pickerActive = pickerEntries.length ? Math.max(0, Math.min(index, pickerEntries.length - 1)) : -1;
const item = pickerList.children[pickerActive];
if (item) {
item.classList.add('active');
if (scroll) item.scrollIntoView({ block: 'nearest' });
pickerFilter.setAttribute('aria-activedescendant', item.id);
} else {
pickerFilter.removeAttribute('aria-activedescendant');
}
}
function pickNode(index) {
const entry = pickerEntries[index];
if (!entry) return;
closePicker(true);
channel.postMessage({ type: 'node-select', data: entry.id });
}
pickerFilter.addEventListener('input', searchNodes);
pickerFilter.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
setPickerActive(pickerActive + 1);
} else if (e.key === 'ArrowUp') {
setPickerActive(pickerActive - 1);
} else if (e.key === 'Enter') {
pickNode(pickerActive);
} else if (e.key === 'Escape') {
closePicker(true);
} else {
return;
}
e.preventDefault();
e.stopPropagation();
});
// Keep focus in the filter while clicking an item
pickerList.addEventListener('mousedown', (e) => e.preventDefault());
pickerList.addEventListener('mouseover', (e) => {
const item = e.target.closest('.dropdown-item');
if (item) setPickerActive(Number(item.dataset.index), false);
});
pickerList.addEventListener('click', (e) => {
const item = e.target.closest('.dropdown-item');
if (item) pickNode(Number(item.dataset.index));
});
picker.addEventListener('focusout', (e) => {
if (e.relatedTarget && !picker.contains(e.relatedTarget) && e.relatedTarget !== gotoBtn) closePicker(false);
});
document.addEventListener('pointerdown', (e) => {
if (!picker.hidden && !picker.contains(e.target) && !gotoBtn.contains(e.target)) closePicker(false);
});
// The queue and the running prompt, null until ComfyUI sends them. Nodes are named by execution id.
let run = null;
function renderRun() {
const running = run.nodeId !== null;
const node = runBar.querySelector('.run-node');
node.textContent = running ? `${run.title || 'Node'} #${run.nodeId}` : run.queue ? 'Queued' : 'Idle';
node.title = node.textContent;
node.classList.toggle('is-running', running);
const percent = running && run.max ? Math.round(run.value / run.max * 100) : 0;
runProgress.firstElementChild.style.width = `${percent}%`;
runProgress.setAttribute('aria-valuenow', String(percent));
runBar.querySelector('.run-queue').textContent = `Queue ${run.queue}`;
const error = run.error;
errorBanner.hidden = !error;
if (error) {
errorBanner.querySelector('.error-node').textContent = `${error.title || error.type} #${error.nodeId}`;
errorBanner.querySelector('.error-message').textContent = error.message;
errorBanner.querySelector('.error-text').title = error.message;
}
renderRunBadge();
}
const RUN_BADGES = { running: 'Running', cached: 'Cached', done: 'Done', error: 'Error' };
// How the shown node did in the running or last prompt
function renderRunBadge() {
const badge = identity.querySelector('.run-badge');
const id = renderedInfo?.node?.executionId;
let state = null;
if (run?.here && id !== undefined) {
if (run.error?.nodeId === id) state = 'error';
else if (run.nodeId === id) state = 'running';
else if (run.cached.includes(id)) state = 'cached';
else if (run.done.includes(id)) state = 'done';
}
badge.hidden = !state;
badge.dataset.state = state ?? '';
badge.textContent = state === 'running' && run.max ? `Running ${run.value}/${run.max}` : RUN_BADGES[state] ?? '';
}
errorBanner.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]');
if (!action || !run?.error) return;
if (action.dataset.action === 'go-to-error') {
channel.postMessage({ type: 'run-node-select', data: run.error.nodeId });
} else if (action.dataset.action === 'copy-traceback') {
copyText(action, run.error.traceback + run.error.message);
}
});
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();
if (state === 'connected') postVisibility();
}
// The ComfyUI tab skips frames while this page is hidden or shows only the Inspector or an output
function postVisibility() {
if (connection !== 'connected') return;
channel.postMessage({ type: 'visibility', data: document.hidden || layout.view === 'output' || (layout.view === 'info' && !document.fullscreenElement) });
}
// 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');
renderZoom();
}
document.addEventListener('visibilitychange', postVisibility);
// ComfyUI pings only while it counts this page as connected; until then announce once a second.
// The window that opened this one gets its handle back after a reload, to draw frames into it.
function announce() {
channel.postMessage({ type: 'pong' });
window.opener?.postMessage({ type: 'magnifyglass-viewer-window', session }, location.origin);
}
setInterval(() => {
if (connection === 'connected' && Date.now() - lastPingTime > 3000) setConnection('inactive');
if (connection !== 'connected') announce();
}, 1000);
announce();
window.addEventListener('beforeunload', () => {
channel.postMessage({ type: 'close' });
});
// Escape closes the picker, or leaves Output; an editor handles its own. ←/→, L and Ctrl+K act like their buttons, outside typing fields
const VIEWER_KEYS = { ArrowLeft: 'prev-node', ArrowRight: 'next-node', l: 'lock-node', L: 'lock-node' };
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!picker.hidden) {
closePicker(true);
} else if (layout.view === 'output') {
setView(glassView);
saveLayout();
}
return;
}
if (document.activeElement?.matches('input, textarea, select')) return;
const action = e.ctrlKey || e.metaKey
? (!e.altKey && e.key.toLowerCase() === 'k' ? 'go-to-node' : null)
: (e.altKey ? null : VIEWER_KEYS[e.key]);
const button = action && inspector.querySelector(`.node-nav [data-action="${action}"]`);
if (!button) return;
e.preventDefault();
if (!button.disabled) button.click();
});
// Fit: the frame fills the area instead of keeping the size set with the handle
const fitBtn = document.getElementById('fit-btn');
const canvasWrapper = document.querySelector('.canvas-wrapper');
function setFitMode(active) {
canvasWrapper.classList.toggle('fit-active', active);
fitBtn.setAttribute('aria-pressed', String(active));
fitBtn.title = active ? 'Restore the set size' : 'Fit to window';
fitBtn.setAttribute('aria-label', fitBtn.title);
}
setFitMode(readStorage('magnifyglass-viewer-fitmode') === 'true');
fitBtn.addEventListener('click', () => {
const active = fitBtn.getAttribute('aria-pressed') !== 'true';
setFitMode(active);
localStorage.setItem('magnifyglass-viewer-fitmode', active);
});
// The corner handle sets the frame's width; the frame stays square and inside its area
const resizeHandle = document.getElementById('resize-handle');
let resizeStart = null;
function setCanvasSize(width) {
canvas.style.setProperty('--canvas-size', `min(${width}px, 100cqw, 100cqh)`);
}
const savedSize = Number(readStorage('magnifyglass-viewer-size'));
if (savedSize > 0) setCanvasSize(savedSize);
resizeHandle.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
resizeHandle.setPointerCapture(e.pointerId);
resizeStart = { x: e.clientX, y: e.clientY, width: canvas.offsetWidth };
e.preventDefault();
});
resizeHandle.addEventListener('pointermove', (e) => {
if (!resizeStart) return;
const delta = Math.max(e.clientX - resizeStart.x, e.clientY - resizeStart.y);
const area = canvasWrapper.getBoundingClientRect();
const max = Math.min(area.width, area.height);
resizeStart.size = Math.round(Math.min(Math.max(200, resizeStart.width + delta), max));
setCanvasSize(resizeStart.size);
});
resizeHandle.addEventListener('lostpointercapture', () => {
if (resizeStart?.size) localStorage.setItem('magnifyglass-viewer-size', resizeStart.size);
resizeStart = null;
});
resizeHandle.addEventListener('dblclick', () => {
canvas.style.removeProperty('--canvas-size');
localStorage.removeItem('magnifyglass-viewer-size');
});
// Split shows the frame and the Inspector, Glass and Info one of them, Output an output in place of the frame
const VIEWS = ['split', 'glass', 'info', 'output'];
function setView(view) {
if (layout.view === 'split' || layout.view === 'glass') glassView = layout.view;
layout.view = VIEWS.includes(view) ? view : 'split';
mainContainer.dataset.view = layout.view;
for (const button of viewButtons) button.setAttribute('aria-pressed', String(button.dataset.view === layout.view));
if (layout.view === 'split' || layout.view === 'output') divider.setAttribute('aria-valuenow', String(Math.round(inspector.getBoundingClientRect().width)));
renderOutputView();
postVisibility();
}
if (layout.inspectorWidth > 0) mainContainer.style.setProperty('--inspector-w', `${layout.inspectorWidth}px`);
setView(layout.view);
for (const button of viewButtons) {
button.addEventListener('click', () => {
setView(button.dataset.view);
saveLayout();
});
}
// The divider sets the Inspector's width, leaving the frame at least 12rem; double-click resets it
let dividerDrag = false;
function setInspectorWidth(width) {
layout.inspectorWidth = width === null ? null : Math.round(Math.max(240, Math.min(width, mainContainer.getBoundingClientRect().width - 192)));
if (layout.inspectorWidth === null) mainContainer.style.removeProperty('--inspector-w');
else mainContainer.style.setProperty('--inspector-w', `${layout.inspectorWidth}px`);
divider.setAttribute('aria-valuenow', String(Math.round(inspector.getBoundingClientRect().width)));
}
divider.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
divider.setPointerCapture(e.pointerId);
divider.classList.add('dragging');
dividerDrag = true;
e.preventDefault();
});
divider.addEventListener('pointermove', (e) => {
if (dividerDrag) setInspectorWidth(mainContainer.getBoundingClientRect().right - e.clientX);
});
divider.addEventListener('lostpointercapture', () => {
if (!dividerDrag) return;
dividerDrag = false;
divider.classList.remove('dragging');
saveLayout();
});
divider.addEventListener('dblclick', () => {
setInspectorWidth(null);
saveLayout();
});
divider.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
// Not Prev/Next node
e.preventDefault();
e.stopPropagation();
setInspectorWidth(inspector.getBoundingClientRect().width + (e.key === 'ArrowLeft' ? 16 : -16));
saveLayout();
});
/**
* Zoom: the buttons and the wheel over the frame change ComfyUI's Zoom Level setting, which
* updates the glass. It is written once they rest for 250 ms, as each write saves the setting.
*/
const ZOOM_MIN = 100;
const ZOOM_MAX = 1000;
const ZOOM_STEP = 25;
// The zoom shown, null until ComfyUI sends it
let zoom = null;
let zoomTimer = null;
let wheelRest = 0;
function setZoom(value) {
if (zoom === null || connection !== 'connected') return;
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value / ZOOM_STEP) * ZOOM_STEP));
if (next === zoom) return;
zoom = next;
renderZoom();
clearTimeout(zoomTimer);
zoomTimer = setTimeout(() => {
zoomTimer = null;
channel.postMessage({ type: 'zoom', data: zoom });
}, 250);
}
function renderZoom() {
const live = zoom !== null && connection === 'connected';
zoomValue.textContent = zoom === null ? '–' : `${zoom}%`;
zoomOut.disabled = !live || zoom <= ZOOM_MIN;
zoomIn.disabled = !live || zoom >= ZOOM_MAX;
}
zoomOut.addEventListener('click', () => setZoom(zoom - ZOOM_STEP));
zoomIn.addEventListener('click', () => setZoom(zoom + ZOOM_STEP));
canvasSection.addEventListener('wheel', (e) => {
// The glass is not shown
if (layout.view === 'output') return;
e.preventDefault();
// A mouse notch is about 100 px; a touchpad's small deltas add up to steps
wheelRest += e.deltaMode ? Math.sign(e.deltaY) * 100 : e.deltaY;
const steps = Math.trunc(wheelRest / 100);
wheelRest -= steps * 100;
if (steps) setZoom(zoom - steps * ZOOM_STEP);
}, { passive: false });
</script>
</body>
</html>