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

1831 lines
68 KiB
HTML
Raw Blame History

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