From 4b06ee92bb1b8d378f845fa8587a710116c4b89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86motion=20Studio?= <163354043+AEmotionStudio@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:47:21 -0800 Subject: [PATCH] Update the Inspector panel look, navigation and editing Restyle the panel on ComfyUI palette tokens and drop the built-in themes. Add a header with Back/Forward, Prev/Next, Focus, Lock and a searchable Go to node picker, a subgraph/group breadcrumb, and simplified modes: Follow glass or Float, plus Sticky, Follow selection and Lock to node (replaces Hold). Fix widget edits (step2 steps, INT/FLOAT, commit on Enter/blur, undo), subgraph lookups, orphaned dropdown menus, global arrow-key capture, the pop-out editor, and remove Google Fonts loading and dead code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 29 + README.md | 40 +- css/info-panel.css | 2068 +++++------------ package.json | 2 +- pyproject.toml | 2 +- src/info-panel/CanvasHighlighter.ts | 53 +- src/info-panel/EventManager.ts | 252 +- src/info-panel/InfoPanel.ts | 190 +- src/info-panel/InformationGatherer.ts | 275 +-- src/info-panel/NodeDataExtractor.ts | 259 +-- src/info-panel/NodeSelector.ts | 77 +- src/info-panel/PositionManager.ts | 76 +- src/info-panel/StateManager.ts | 428 +--- src/info-panel/UIManager.ts | 2006 ++++++---------- src/info-panel/ValueFormatter.ts | 100 +- .../widget-editors/DragValueController.ts | 150 +- .../widget-editors/InlineControlFactory.ts | 74 +- .../widget-editors/WidgetEditorFactory.ts | 327 +-- .../widget-editors/WidgetSyncManager.ts | 237 +- src/magnify-glass/EventHandler.ts | 4 +- src/magnify-glass/MagnifyGlass.ts | 49 +- src/magnify-glass/PopOutManager.ts | 66 +- src/magnify_info_panel.ts | 4 + src/shared/CustomDropdown.ts | 272 +-- src/shared/constants.ts | 21 +- src/shared/icons.ts | 89 +- src/shared/settings.ts | 163 -- src/shared/settings/defaults.ts | 6 +- src/shared/settings/panelSettings.ts | 59 +- src/shared/settings/types.ts | 5 +- src/sidebar/SidebarSettings.ts | 37 +- src/types/comfyui.d.ts | 102 +- tests/unit/CustomDropdown.test.ts | 214 ++ tests/unit/InfoPanelLifecycle.test.ts | 937 +++++++- tests/unit/InformationGatherer.test.ts | 72 +- tests/unit/NodeSelector.test.ts | 91 +- tests/unit/PopOutTheme.test.ts | 46 + tests/unit/PopOutViewer.test.ts | 116 + tests/unit/Security.test.ts | 124 +- tests/unit/StateManager.test.ts | 257 +- tests/unit/StateManagerValidation.test.ts | 79 +- tests/unit/UIManager_Dropdown.test.ts | 307 ++- tests/unit/WidgetEditing.test.ts | 271 +++ vite.config.ts | 5 - web/info-panel.css | 2068 +++++------------ web/info-panel/CanvasHighlighter.js | 45 +- web/info-panel/EventManager.js | 193 +- web/info-panel/InfoPanel.js | 147 +- web/info-panel/InformationGatherer.js | 244 +- web/info-panel/NodeDataExtractor.js | 97 +- web/info-panel/NodeSelector.js | 52 +- web/info-panel/PositionManager.js | 66 +- web/info-panel/StateManager.js | 314 +-- web/info-panel/UIManager.js | 1594 +++++-------- web/info-panel/ValueFormatter.js | 50 +- .../widget-editors/DragValueController.js | 113 +- .../widget-editors/InlineControlFactory.js | 66 +- .../widget-editors/WidgetEditorFactory.js | 294 +-- .../widget-editors/WidgetSyncManager.js | 190 +- web/magnify-glass/EventHandler.js | 4 +- web/magnify-glass/MagnifyGlass.js | 40 +- web/magnify-glass/PopOutManager.js | 55 +- web/magnify_glass.js | 1 + web/magnify_info_panel.js | 6 + web/popout-viewer.html | 489 ++-- web/shared/CustomDropdown.js | 226 +- web/shared/constants.js | 28 +- web/shared/icons.js | 64 +- web/shared/settings/defaults.js | 10 +- web/shared/settings/panelSettings.js | 59 +- web/sidebar/SidebarAbout.js | 4 +- web/sidebar/SidebarSettings.js | 50 +- 72 files changed, 6060 insertions(+), 10550 deletions(-) delete mode 100644 src/shared/settings.ts create mode 100644 tests/unit/CustomDropdown.test.ts create mode 100644 tests/unit/PopOutTheme.test.ts create mode 100644 tests/unit/PopOutViewer.test.ts create mode 100644 tests/unit/WidgetEditing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db5ad7..0cfe403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.15.0] - 2026-11-04 + +### Changed +- **Inspector look**: The panel, its toolbar and its menus follow your ComfyUI palette (any palette, light or dark) with a flat, denser layout. Text and Accent Color override the palette only when changed from their defaults. Max Height and Opacity now apply in every mode. +- **Header and navigation**: The header shows the node's title, `#id` and type, with Lock and Minimize (double-click the header to minimize). Below it: Back/Forward, Previous/Next in execution order, Centre on node, and a searchable **Go to node** picker (title, type or `#id`) that replaces the three node lists. A breadcrumb shows the subgraph path and group, with "Enter subgraph" for subgraph nodes. Every jump centres the canvas and locks the panel to the node. +- **Simpler modes**: The panel either follows the glass or floats. Dragging the header floats it, and its position and minimized state are kept per browser. A corner handle sets Width and Max Height. **Sticky** (S) keeps the last node, **Lock** keeps one node while you hover others, and the new **Follow Selection** setting shows the selected node. +- **Editing**: Widgets are editable while Sticky, Locked or following the selection; otherwise rows offer "Lock to edit". Number steps match the canvas (Shift ×10, Alt ×0.1), text commits on Enter or blur and Escape reverts, prompts get a multiline editor (Ctrl+Enter), and a drag is one undo step. Linked widgets are read-only. +- **Copy**: Every row can be copied, the header copies `Title #id`, and copying works on plain-http LAN addresses. +- **Toolbar**: A fixed set of grouped buttons; ones that can't act are disabled. All eight Controls Positions work, and the toolbar flips sides instead of covering the panel. +- **Hotkeys**: Panel keys act only while the glass is on and never with Ctrl or Meta. `*` and ←/→ act only while the panel is hovered or focused, so the arrow keys nudge the glass otherwise. Rebinding applies at once. +- Max Height now defaults to 600px. + +### Fixed +- **Subgraphs**: The panel, picker, highlight and editing use the graph on screen, so they work inside subgraphs. Title bars and collapsed nodes are hit. +- **Widget edits** run the widget's callback, redraw the canvas and record undo. CLIPLoader's `type` widget shows again. +- Dropdowns no longer get orphaned or keep writing after the panel re-renders, and they can be filtered by typing. +- The panel re-renders only when the shown node changes, so hover, focus and text selection survive. +- Saved fonts no longer reset on reload, and High Contrast Text applies on load. +- Node modes read Mute and Bypass instead of Never and On Request. +- The pop-out editor keeps newlines, handles widget names with quotes, and Escape no longer closes the window. + +### Removed +- The built-in panel themes and their palette detection. +- Hold (P); use Lock. +- Loading fonts from Google Fonts. Font Family uses fonts installed locally. +- The Inspector and Media sections, the Show Inspector Tab setting, the guessed Model/Image/Text rows, and the unused Animations setting. + +--- + ## [1.14.0] - 2026-11-03 ### Fixed diff --git a/README.md b/README.md index 04f9c0a..068bd70 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **A powerful, customizable magnifying glass extension for ComfyUI.** [![ComfyUI](https://img.shields.io/badge/ComfyUI-Extension-green?style=for-the-badge)](https://github.com/comfyanonymous/ComfyUI) -[![Version](https://img.shields.io/badge/Version-1.14.0-orange?style=for-the-badge)](https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass/releases) +[![Version](https://img.shields.io/badge/Version-1.15.0-orange?style=for-the-badge)](https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass/releases) [![License](https://img.shields.io/badge/License-GPLv3-red?style=for-the-badge)](LICENSE) [![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen?style=for-the-badge&color=blue)](package.json) @@ -25,14 +25,14 @@ --- -## 🚀 What's New in v1.14.0 (November 3, 2026) +## 🚀 What's New in v1.15.0 (November 4, 2026) -**Images and Videos in the Glass, Glass-Only Mode, New Sidebar** +**Inspector Overhaul: New Look, Navigation and Modes** -* **🖼️ Media in the Glass**: Image previews, videos and animated images now show in the glass at their real position, at any canvas zoom. Images are drawn at full resolution, videos play smoothly, and both reach the pop-out and the Invert/Grayscale filters. -* **🔍 Glass-Only Mode**: Turn off **Inspector panel** at the top of the sidebar to remove the panel, its hotkeys and its highlight entirely. Turn it back on without a reload. -* **🎨 Sidebar Redesign**: Follows your ComfyUI palette (light or dark), with an About section for links, version and the studio's other packs. -* **🧩 Nodes 2.0**: Basic support for the Vue nodes mode. +* **🎨 Follows Your Palette**: The Inspector, its toolbar and menus use your ComfyUI palette, light or dark. The built-in themes are gone. +* **🧭 Header and Go to Node**: Title, `#id` and type in the header, Back/Forward, Previous/Next in execution order, and a searchable node picker. Subgraph path and group show as a breadcrumb. +* **📌 Simpler Modes**: Follow the glass or drag the header to float. **Sticky**, **Lock** and the new **Follow Selection** decide which node is shown. Hold is replaced by Lock. +* **✏️ Reliable Editing**: Canvas-matching number steps, commit on Enter, revert on Escape, multiline prompts, undo, and subgraph support. > 📄 **See [CHANGELOG.md](CHANGELOG.md) for the complete version history.** @@ -61,9 +61,9 @@ ### ℹ️ Inspector Panel ![Inspector Panel](https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass/releases/download/assets-v1/MagnifyGlass_InspectorPanel_v1.webp) -* **Deep Analysis**: View node parameters (Seed, CFG, Steps), text content, and image details on hover. -* **Dockable Interface**: Pin the panel to keep it stable, or let it follow the glass. -* **Themed**: Automatically matches any ComfyUI theme (Dark, Light, Solarized, Arc, Nord, GitHub). +* **Live Widgets**: Every widget of the hovered node, editable while Sticky, Locked or following the selection, with copy on every row. +* **Navigation**: Go to any node by title, type or `#id`, step through execution order, and go Back/Forward through your jumps. +* **Follows Your Palette**: Matches any ComfyUI palette. Follow the glass or drag the header to float and resize it. @@ -134,10 +134,11 @@ git clone https://github.com/AEmotionStudio/ComfyUI-MagnifyGlass.git | **`I`** | Toggle Inspector Panel Visibility | | **`G`** | Toggle Glass Preview (with the Inspector panel on, keeps the panel and hides the glass) | | **`Shift+P`** | Open Pop-Out Viewer in New Tab | -| **`U`** | Pin/Unpin Inspector Panel | +| **`U`** | Inspector: Follow the Glass / Float | +| **`S`** | Inspector: Toggle Sticky | | **`O`** | Reset Offsets | -| **`*`** | **Focus Current Node** (Center canvas on inspected node) | -| **`Left/Right`** | Navigate Previous/Next node in execution order | +| **`*`** | **Centre on Node** (centres the canvas and locks the Inspector to it) | +| **`Left/Right`** | Previous/Next node in execution order (while the Inspector is hovered or focused) | | **`D`** | Force Direct Capture (Optional after v1.11.0 improvements) | | **Arrows** | Nudge Glass Position (When not navigating nodes) | @@ -177,19 +178,22 @@ Access settings via the **new Sidebar Panel** or the ComfyUI Settings (⚙️) m | Setting | Default | Description | | :--- | :--- | :--- | | **Inspector panel** | `On` | Off leaves only the glass: the panel, its hotkeys (`I`, `U`, `S`, `*`) and its highlight are removed. | -| **Persist Info** | `Off` | Keep last node info visible when not hovering a node. | +| **Sticky** | `Off` | Keep the last node shown when not hovering a node. | +| **Follow Selection** | `Off` | Show the selected node instead of the hovered one. | | **Node Highlight** | `On` | Show high-contrast border around inspected node. | | **Position** | `Bottom` | Position relative to the glass (Top/Bottom/Left/Right). | | **Width** | `300px` | Width of the inspector panel. | -| **Max Height** | `300px` | Maximum vertical size before scrolling. | +| **Max Height** | `600px` | Maximum height before the panel scrolls. | | **Font Size** | `14px` | Size of the text in the inspector. | | **Opacity** | `100%` | Background transparency of the panel. | -| **Font Family** | `System` | Custom font selection for the panel. | -| **Text Color** | `#6B7280` | Custom text color for node data. | -| **Accent Color** | `#3B82F6` | Color for headers and highlights. | +| **Font Family** | `System` | Font for the panel; only fonts installed locally are used. | +| **Text Color** | `#6B7280` | Overrides the palette's text color when changed from the default. | +| **Accent Color** | `#3B82F6` | Overrides the palette's accent color when changed from the default. | | **Hover Controls** | `On` | Show floating quick-action buttons. | | **Controls Position** | `Left` | Anchor point for the floating controls. | +> Drag the panel's header to float it, and its corner to set Width and Max Height. **Lock** in the header keeps one node shown while you hover others. +> > **Move Glass Position** (drag-glass mode) is one of the panel's floating controls, so it is unavailable while the Inspector panel is off. Use Follow Cursor (`H`) or the arrow keys instead. diff --git a/css/info-panel.css b/css/info-panel.css index 12ab388..15b6ce0 100644 --- a/css/info-panel.css +++ b/css/info-panel.css @@ -1,1627 +1,813 @@ /** - * ComfyUI MagnifyGlass - Info Panel Styles - * - * Complete CSS styles for the info panel component. + * ComfyUI MagnifyGlass - Info Panel CSS + * + * Built on the same ComfyUI tokens as the sidebar (sidebar.css), so the panel follows + * every palette. The toolbar, the menus and the node highlight are appended to body, + * so they take the tokens themselves. Text and accent colours changed in the settings + * arrive on the root as --mg-panel-fg and --mg-panel-accent. */ -/* SVG Icon Styling */ -.magnify-info-panel svg, -.floating-controls svg { - width: 1em; - height: 1em; - vertical-align: middle; - fill: none; - stroke: currentColor; - pointer-events: none; - /* Allow clicks to pass through to button */ -} - -.header-icon svg { - width: 18px; - height: 18px; -} - -.section-icon svg { - width: 18px; - height: 18px; -} - -.control-btn svg { - width: 14px; - height: 14px; -} - -.expand-icon svg { - width: 12px; - height: 12px; -} - -.empty-state-icon svg { - width: 36px; - height: 36px; - stroke-width: 1.5; +.magnify-info-panel, +.floating-controls, +.node-selector-dropdown, +.custom-dropdown-menu, +#magnify-glass-node-highlight { + --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); } .magnify-info-panel { - border-radius: 12px; - backdrop-filter: blur(20px); - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1); - /* font-size sets the base 1em for children */ - overflow: visible; - line-height: 1.6; - pointer-events: auto; + --mg-fg: var(--mg-panel-fg, var(--fg-color, #fff)); + + position: absolute; + z-index: 99999; display: flex; flex-direction: column; -} - -.magnify-info-panel * { - pointer-events: auto; -} - -.magnify-info-panel.theme-dark { - background: linear-gradient(135deg, rgb(0, 0, 0), rgb(10, 10, 15)); - color: var(--info-panel-text-color, #e0e0e0); - border: 1px solid rgba(100, 100, 120, 0.4); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 2px 16px rgba(0, 0, 0, 0.2); - backdrop-filter: blur(12px); -} - -.magnify-info-panel.theme-light { - background: linear-gradient(135deg, rgb(250, 250, 255), rgb(240, 240, 250)); - color: var(--info-panel-text-color, #2a2a2a); - border: 1px solid rgba(200, 200, 220, 0.5); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 2px 16px rgba(0, 0, 0, 0.05); - backdrop-filter: blur(12px); -} - -/* Solarized Theme - warm, muted tones */ -.magnify-info-panel.theme-solarized { - background: linear-gradient(135deg, rgb(0, 43, 54), rgb(7, 54, 66)); - color: var(--info-panel-text-color, #839496); - border: 1px solid rgba(88, 110, 117, 0.5); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 2px 16px rgba(0, 0, 0, 0.2); - backdrop-filter: blur(12px); - --info-panel-accent-color: #268bd2; -} - -/* Arc Theme - modern blue-gray */ -.magnify-info-panel.theme-arc { - background: linear-gradient(135deg, rgb(56, 60, 74), rgb(64, 69, 82)); - color: var(--info-panel-text-color, #d3dae3); - border: 1px solid rgba(90, 100, 120, 0.5); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), 0 2px 16px rgba(0, 0, 0, 0.15); - backdrop-filter: blur(12px); - --info-panel-accent-color: #5294e2; -} - -/* Nord Theme - cool arctic colors */ -.magnify-info-panel.theme-nord { - background: linear-gradient(135deg, rgb(46, 52, 64), rgb(59, 66, 82)); - color: var(--info-panel-text-color, #d8dee9); - border: 1px solid rgba(76, 86, 106, 0.5); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), 0 2px 16px rgba(0, 0, 0, 0.15); - backdrop-filter: blur(12px); - --info-panel-accent-color: #88c0d0; -} - -/* GitHub Theme - clean, professional */ -.magnify-info-panel.theme-github { - background: linear-gradient(135deg, rgb(255, 255, 255), rgb(246, 248, 250)); - color: var(--info-panel-text-color, #24292e); - border: 1px solid rgba(225, 228, 232, 0.8); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 2px 16px rgba(0, 0, 0, 0.05); - backdrop-filter: blur(12px); - --info-panel-accent-color: #0366d6; -} - -/* Obsidian Theme - deep purple/charcoal */ -.magnify-info-panel.theme-obsidian { - background: linear-gradient(135deg, rgb(30, 30, 40), rgb(40, 40, 55)); - color: var(--info-panel-text-color, #d4d4d8); - border: 1px solid rgba(80, 80, 100, 0.5); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 2px 16px rgba(0, 0, 0, 0.2); - backdrop-filter: blur(12px); - --info-panel-accent-color: #8b5cf6; -} - -/* Obsidian Dark Theme - even darker obsidian */ -.magnify-info-panel.theme-obsidian-dark { - background: linear-gradient(135deg, rgb(15, 15, 20), rgb(25, 25, 35)); - color: var(--info-panel-text-color, #c4c4cc); - border: 1px solid rgba(60, 60, 80, 0.5); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 16px rgba(0, 0, 0, 0.3); - backdrop-filter: blur(12px); - --info-panel-accent-color: #a78bfa; -} - -/* Milk White Theme - soft cream/white */ -.magnify-info-panel.theme-milk-white { - background: linear-gradient(135deg, rgb(255, 253, 250), rgb(250, 248, 245)); - color: var(--info-panel-text-color, #3d3d3d); - border: 1px solid rgba(220, 215, 210, 0.8); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 16px rgba(0, 0, 0, 0.04); - backdrop-filter: blur(12px); - --info-panel-accent-color: #7c3aed; -} - - -.magnify-info-panel.visible { - transform: translateY(0) !important; -} - -.magnify-info-panel.panel-pinned { - cursor: grab; - box-shadow: 0 0 0 2px rgba(255, 215, 0, 0.5), 0 20px 40px rgba(0, 0, 0, 0.3); -} - -.magnify-info-panel.panel-locked { - cursor: default !important; - box-shadow: 0 0 0 2px rgba(255, 69, 0, 0.5), 0 20px 40px rgba(0, 0, 0, 0.3); -} - -.magnify-info-panel.panel-locked::after { - content: '📌'; - position: absolute; - top: 8px; - right: 8px; - font-size: 14px; - opacity: 0.7; - pointer-events: none; - z-index: 1; -} - -.magnify-info-panel.panel-pinned.panel-dragging { - cursor: grabbing; - box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(255, 215, 0, 0.7); - transform: scale(1.02) !important; - z-index: 100001 !important; - transition: none !important; -} - -.magnify-info-panel.panel-pinned::before { - content: ''; - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - background: linear-gradient(45deg, rgba(255, 215, 0, 0.05) 0%, transparent 100%); - pointer-events: none; - border-radius: inherit; -} - -.panel-dragging * { - pointer-events: none !important; - user-select: none !important; - transition: none !important; -} - -.magnify-info-panel.panel-minimized .panel-content { - max-height: 0 !important; - height: 0 !important; overflow: hidden; - padding: 0 !important; - margin: 0 !important; - transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1); + color: var(--mg-fg); + background: color-mix(in srgb, var(--mg-surface) var(--panel-opacity, 100%), transparent); + border: 1px solid var(--mg-stroke); + border-radius: 0.5rem; + box-shadow: var(--mg-shadow); + line-height: 1.35; } -/* Hover-expand: Show ALL content at once - no scrollbar, full expansion */ -.magnify-info-panel.is-expanded:not(.panel-minimized) { - max-height: none !important; - overflow: visible !important; +.magnify-info-panel.high-contrast-text { + --mg-muted: var(--mg-fg); } -.magnify-info-panel.is-expanded:not(.panel-minimized) .panel-content { - max-height: none !important; - overflow: visible !important; +.magnify-info-panel.high-contrast-text :is(.info-value, input, textarea) { + font-weight: 600; } -/* - * STICKY MODE (PERSIST) OVERRIDES - * When sticky mode is active, the panel stays on the selected node - * and doesn't auto-minimize when moving to empty canvas. - * Content shows with scrolling if it exceeds viewport height. - */ -.magnify-info-panel.persist-active:not(.panel-minimized) { - /* Use viewport height as max, with some margin for top positioning */ - max-height: calc(100vh - 40px) !important; - overflow: hidden !important; - display: flex !important; - flex-direction: column !important; +.magnify-info-panel *, +.magnify-info-panel *::before, +.magnify-info-panel *::after, +.floating-controls * { + box-sizing: border-box; } -.magnify-info-panel.persist-active:not(.panel-minimized) .panel-content { - /* Enable scrolling for content that exceeds the height */ - overflow-y: auto !important; - /* Calculate height based on available space minus header (~60px) */ - max-height: calc(100vh - 120px) !important; - flex: 1 1 auto; - scrollbar-width: thin; +.magnify-info-panel :where(button, input, textarea), +.floating-controls :where(button) { + font: inherit; + color: inherit; } +.magnify-info-panel svg, +.floating-controls svg, +.node-selector-dropdown svg { + flex: none; + width: 1em; + height: 1em; + pointer-events: none; +} + +.magnify-info-panel [hidden] { + display: none !important; +} + +.magnify-info-panel :focus-visible, +.floating-controls :focus-visible, +.node-selector-dropdown :focus-visible, +.custom-dropdown-menu :focus-visible { + outline: 2px solid var(--mg-ring); + outline-offset: 2px; +} + +/* Icon buttons: the toolbar and the header */ +.control-btn, +.copy-btn, +.widget-editor-stepper { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 2em; + height: 2em; + padding: 0; + border: 0; + border-radius: 0.375rem; + background: transparent; + color: var(--mg-muted); + cursor: pointer; + transition: background-color 120ms var(--mg-ease), color 120ms var(--mg-ease); +} + +.control-btn:hover, +.copy-btn:hover, +.widget-editor-stepper:hover { + background: var(--mg-control-hover); + color: var(--mg-fg); +} + +.control-btn.active, +.copy-btn.copied { + background: color-mix(in srgb, var(--mg-accent) 18%, transparent); + color: var(--mg-accent-fg); +} + +.copy-btn.copied { + opacity: 1; +} + +.control-btn:disabled { + background: transparent; + color: var(--mg-muted); + opacity: 0.4; + cursor: not-allowed; +} + +/* Toolbar */ .floating-controls { position: fixed; - display: none; - /* Hidden initially - JS will show when positioned */ - padding: 4px; - background: rgba(40, 40, 50, 0.95); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 4px; - opacity: 1; z-index: 100000; - backdrop-filter: blur(8px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + gap: 0.125rem; + padding: 0.25rem; + color: var(--mg-fg); + background: var(--mg-surface); + border: 1px solid var(--mg-stroke); + border-radius: 0.5rem; + box-shadow: var(--mg-shadow); + font-size: 0.875rem; } .floating-controls.vertical-layout { flex-direction: column; - gap: 4px; } -.floating-controls.horizontal-layout { - flex-direction: row; - gap: 4px; +.controls-divider { + flex: none; + align-self: stretch; + margin: 0.125rem; + background: var(--mg-stroke); } -.control-btn { - background: rgba(60, 60, 70, 0.8); - border: 1px solid rgba(255, 255, 255, 0.2); - width: 28px; - height: 28px; - font-size: 14px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: #ddd; - border-radius: 3px; - transition: background-color 0.15s ease; +.vertical-layout .controls-divider { + height: 1px; } -.control-btn:hover { - background: rgba(80, 80, 90, 0.9); - color: white; -} - -.control-btn:active { - background: rgba(100, 100, 110, 0.9); -} - -.control-btn:disabled { - background: rgba(40, 40, 50, 0.5); - color: #666; - cursor: not-allowed; - opacity: 0.5; -} - -.control-btn.active { - background: rgba(255, 200, 50, 0.3); - border-color: rgba(255, 200, 50, 0.6); - color: #ffcc33; -} - -.control-btn.lock-btn.active { - background: rgba(255, 69, 0, 0.3); - border-color: rgba(255, 69, 0, 0.6); - color: #ff4500; +.horizontal-layout .controls-divider { + width: 1px; } +/* Header: the node's identity, then the navigation row and the breadcrumb */ .panel-header { display: flex; align-items: center; - justify-content: space-between; - padding: 14px 18px; - background: linear-gradient(135deg, rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.05)); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - user-select: none; + gap: 0.25em; + min-height: 2.75em; + padding: 0.25em 0.375em 0.25em 0.75em; + border-bottom: 1px solid var(--mg-stroke); + /* Dragging it floats the panel */ cursor: grab; - transition: background 0.15s ease; + touch-action: none; + user-select: none; } -.panel-header:hover { - background: linear-gradient(135deg, rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.08)); -} - -.panel-header:active { +.magnify-info-panel.is-dragging, +.magnify-info-panel.is-dragging .panel-header { cursor: grabbing; } -.header-content { - display: flex; - align-items: center; - gap: 10px; +.magnify-info-panel.is-dragging, +.info-row.draggable .info-label { + user-select: none; } -.header-icon { - font-size: 18px; - opacity: 0.9; +.panel-minimized .panel-header { + border-bottom: 0; +} + +.header-identity { + display: flex; + flex: 1; + align-items: baseline; + gap: 0.375em; + min-width: 0; +} + +.header-title, +.header-badge, +.goto-btn span, +.crumb, +.crumb-group { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .header-title { + font-weight: 700; +} + +.header-title.is-empty { + color: var(--mg-muted); font-weight: 600; - font-size: 18px; } -.header-subtitle { - font-size: 14px; - opacity: 0.7; - font-style: italic; +.header-id { + flex: none; + color: var(--mg-muted); + font-size: 0.875em; + font-variant-numeric: tabular-nums; } -.header-controls { - display: flex; - gap: 6px; -} - -.minimize-btn { - background: rgba(255, 255, 255, 0.1); - border: none; - border-radius: 6px; - color: inherit; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; - font-weight: bold; - font-size: 16px; -} - -.minimize-btn:hover { - background: rgba(255, 255, 255, 0.2); - transform: scale(1.05); -} - -.minimize-btn:active { - transform: scale(0.95); -} - -.panel-content { - padding: 0; - flex: 1; - min-height: 0; - overflow: visible; - scrollbar-width: thin; -} - -.panel-content::-webkit-scrollbar { - width: 8px; -} - -.panel-content::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.1); -} - -.panel-content::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 3px; -} - -.info-section { - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - margin-bottom: 8px; - border-radius: 10px; - overflow: visible; - background: rgba(255, 255, 255, 0.02); - transition: all 0.3s ease; -} - -.info-section:hover { - background: rgba(255, 255, 255, 0.04); - /* Removed transform to prevent bounce */ -} - -.section-header { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 20px; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +.header-badge { + padding: 0.125em 0.5em; + border-radius: 1em; + background: var(--mg-control); + color: var(--mg-muted); + font-size: 0.8em; font-weight: 500; - border-radius: 8px; - margin: 3px; - position: relative; - overflow: hidden; + flex: 0 4 auto; + min-width: 3.5em; + max-width: 40%; } -.section-header::before { - content: ''; +.panel-nav { + display: flex; + align-items: center; + gap: 0.125em; + padding: 0.25em 0.375em; + border-bottom: 1px solid var(--mg-stroke); +} + +.goto-btn { + display: flex; + flex: 1; + align-items: center; + gap: 0.375em; + min-width: 0; + height: 2em; + margin-left: 0.25em; + padding: 0 0.5em; + border: 1px solid var(--mg-stroke); + border-radius: 0.375rem; + background: var(--mg-control); + color: var(--mg-muted); + cursor: pointer; +} + +.goto-btn:hover, +.goto-btn[aria-expanded="true"] { + background: var(--mg-control-hover); + color: var(--mg-fg); +} + +.panel-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25em 0.375em; + padding: 0.25em 0.75em; + border-bottom: 1px solid var(--mg-stroke); + color: var(--mg-muted); + font-size: 0.875em; +} + +.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); + cursor: pointer; +} + +.crumb-action:hover { + background: var(--mg-control-hover); +} + +/* Body: the panel's one scroller, capped by the Max Height setting */ +.panel-content { + flex: 1 1 auto; + min-height: 0; + max-height: min(var(--panel-max-height, 600px), calc(100vh - 5rem)); + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--mg-stroke) transparent; +} + +.panel-minimized :is(.panel-nav, .panel-breadcrumb, .panel-content, .panel-resize) { + display: none; +} + +/* Corner handle: Width and Max Height */ +.panel-resize { position: absolute; - top: 0; - left: 0; right: 0; bottom: 0; - background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.05) 50%, transparent 100%); - transform: translateX(-100%); - transition: transform 0.6s ease; + width: 0.875rem; + height: 0.875rem; + cursor: nwse-resize; + touch-action: none; } -.section-header:hover::before { - transform: translateX(100%); -} - -.section-header:hover { - background: rgba(255, 255, 255, 0.08); - /* Removed transform: translateX(2px) */ - box-shadow: inset 2px 0 0 rgba(116, 185, 255, 0.5); -} - -.section-header:active { - background: rgba(255, 255, 255, 0.12); - transform: translateX(1px); -} - -.section-header.expanded { - background: rgba(116, 185, 255, 0.1); - border-left: 2px solid var(--info-panel-accent-color, #74b9ff); -} - -.info-section[data-section="node"] { - background: linear-gradient(135deg, rgba(116, 185, 255, 0.05), rgba(160, 212, 104, 0.05)); - border: 1px solid rgba(116, 185, 255, 0.2); - border-radius: 8px; - margin: 6px; -} - -.info-section[data-section="node"] .section-header { - background: linear-gradient(135deg, rgba(116, 185, 255, 0.15), rgba(160, 212, 104, 0.15)); - border-radius: 8px 8px 0 0; - cursor: default; -} - -.info-section[data-section="node"] .section-header:hover { - transform: none; - box-shadow: none; -} - -.info-section[data-section="node"] .section-title { - color: var(--info-panel-accent-color, #74b9ff); - font-weight: 700; -} - -.info-section[data-section="inspector"] { - background: linear-gradient(135deg, rgba(100, 150, 255, 0.05), rgba(74, 144, 226, 0.05)); - border: 1px solid rgba(100, 150, 255, 0.2); - border-radius: 8px; - margin: 6px; -} - -.info-section[data-section="inspector"] .section-header { - background: linear-gradient(135deg, rgba(100, 150, 255, 0.15), rgba(74, 144, 226, 0.15)); - border-radius: 8px 8px 0 0; -} - -.info-section[data-section="inspector"] .section-title { - color: #6496ff; - font-weight: 700; -} - -.info-section[data-section="media"] { - background: linear-gradient(135deg, rgba(253, 203, 110, 0.05), rgba(255, 215, 0, 0.05)); - border: 1px solid rgba(253, 203, 110, 0.2); - border-radius: 8px; - margin: 6px; -} - -.info-section[data-section="media"] .section-header { - background: linear-gradient(135deg, rgba(253, 203, 110, 0.15), rgba(255, 215, 0, 0.15)); - border-radius: 8px 8px 0 0; -} - -.info-section[data-section="media"] .section-title { - color: var(--info-panel-accent-color, #fdcb6e); - font-weight: 700; -} - -.section-icon { - font-size: 18px; - width: 22px; - text-align: center; -} - -.section-title { - flex: 1; - font-size: 1.1em; - font-weight: 600; - letter-spacing: 0.3px; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); -} - -.section-badge { - background: rgba(100, 150, 255, 0.2); - color: #8bb3ff; - padding: 3px 10px; - border-radius: 10px; - font-size: 0.85em; - font-weight: 500; -} - -.expand-icon { - font-size: 14px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - color: rgba(255, 255, 255, 0.6); - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - background: rgba(255, 255, 255, 0.05); - margin-left: auto; -} - -.section-header:hover .expand-icon { - color: var(--info-panel-accent-color, #74b9ff); - background: rgba(116, 185, 255, 0.15); - transform: scale(1.1); -} - -.section-header.expanded .expand-icon { - transform: rotate(90deg) scale(1.1); - color: var(--info-panel-accent-color, #74b9ff); - background: rgba(116, 185, 255, 0.2); -} - -.section-content { - max-height: 0; - overflow: visible; - transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease; - opacity: 0; -} - -.section-content.expanded { - max-height: 2000px; - opacity: 1; - overflow: visible; -} - -.section-body { - padding: 0 18px 16px 24px; -} - -.info-row { - display: flex; - justify-content: space-between; - align-items: flex-start; - padding: 10px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); - transition: border-bottom-color 0.2s ease; - border-radius: 4px; - margin: 0 -8px; - padding-left: 8px; - padding-right: 8px; - /* Create stacking context so ::before stays behind interactive controls */ - position: relative; - isolation: isolate; -} - -/* Hover background via pseudo-element - placed behind all interactive controls */ -.info-row::before { +.panel-resize::after { content: ''; position: absolute; - inset: 0; - border-radius: 4px; - background: transparent; - z-index: -1; - transition: background 0.2s ease; - pointer-events: none; -} - -.info-row:hover::before { - background: rgba(255, 255, 255, 0.03); -} - -.info-row:last-child { - border-bottom: none; -} - -.info-row:hover { - /* Background now handled by ::before pseudo-element */ - border-bottom-color: rgba(255, 255, 255, 0.08); -} - -.info-label { - font-weight: 500; - opacity: 0.85; - font-size: 1em; - min-width: 80px; - padding-right: 12px; - line-height: 1.5; -} - -.info-value { - font-family: inherit; - font-size: 0.95em; - color: var(--info-panel-accent-color, #a0d468); - background: rgba(160, 212, 104, 0.12); - padding: 4px 10px; - border-radius: 6px; - max-width: 100%; - word-wrap: break-word; - word-break: break-word; - hyphens: auto; - line-height: 1.4; - text-align: left; - white-space: pre-wrap; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; -} - -.info-value:hover { - background: rgba(160, 212, 104, 0.18); - /* Removed transform: translateY(-1px) */ - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -.info-value.long-text { - white-space: pre-wrap; - word-wrap: break-word; - max-width: 100%; - line-height: 1.4; -} - -/* Copy button wrapper for copyable rows */ -.info-value-wrapper { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - justify-content: flex-end; -} - -/* Copyable rows need relative positioning for absolute copy button */ -.copyable-row { - position: relative; -} - -/* Copy to clipboard button - positioned in top right corner */ -.copy-btn { - position: absolute !important; - top: 4px !important; - right: 4px !important; - display: flex; - align-items: center; - justify-content: center; - background: rgba(40, 40, 50, 0.9); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - color: rgba(255, 255, 255, 0.7); - cursor: pointer; - padding: 3px 5px; - opacity: 0.7; - transition: all 0.2s ease; - z-index: 10; -} - -.copy-btn:hover { - background: rgba(116, 185, 255, 0.3); - border-color: var(--info-panel-accent-color, #74b9ff); - color: var(--info-panel-accent-color, #74b9ff); -} - -.copy-btn:active { - transform: scale(0.95); -} - -.copy-btn.copied { - opacity: 1; - background: rgba(160, 212, 104, 0.3); - border-color: rgba(160, 212, 104, 0.6); - color: #a0d468; -} - -.copy-btn svg { - width: 12px; - height: 12px; - stroke: currentColor; + right: 3px; + bottom: 3px; + width: 6px; + height: 6px; + border-right: 2px solid var(--mg-muted); + border-bottom: 2px solid var(--mg-muted); } .empty-state { - text-align: center; - padding: 50px 25px; - opacity: 0.6; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5em; + padding: 2em 1em; + color: var(--mg-muted); } .empty-state-icon { - font-size: 36px; - margin-bottom: 15px; - opacity: 0.5; + display: flex; + font-size: 2em; } -.empty-state-text { - font-size: 16px; - font-style: italic; +.info-rows { + padding-bottom: 0.375em; } -/* Copy to clipboard functionality */ -.info-value[title]:hover::after { - content: '📋'; - position: absolute; - right: -20px; - top: 50%; - transform: translateY(-50%); - font-size: 12px; - opacity: 0.7; +/* Rows: label and value side by side */ +.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.25em 0.75em; } -/* Smooth loading states */ -.section-body.loading { - opacity: 0.5; - pointer-events: none; +.info-label { + grid-area: 1 / 1; + min-width: 0; + /* Widget names break after their underscores */ + overflow-wrap: break-word; + color: var(--mg-muted); } -.section-body.loading::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 20px; - height: 20px; - border: 2px solid transparent; - border-top: 2px solid var(--info-panel-accent-color, #74b9ff); - border-radius: 50%; - animation: spin 1s linear infinite; - transform: translate(-50%, -50%); +.info-value, +.inline-control-container, +.widget-editor-container { + grid-area: 1 / 2; + min-width: 0; } -@keyframes spin { - to { - transform: translate(-50%, -50%) rotate(360deg); - } +.info-value { + overflow-wrap: anywhere; + white-space: pre-wrap; + font-variant-numeric: tabular-nums; } -/* Enhanced scrollbar for better UX */ -.section-body::-webkit-scrollbar { - width: 4px; +.copy-btn { + grid-area: 1 / 3; + width: 1.75em; + height: 1.75em; + opacity: 0; } -.section-body::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.05); - border-radius: 2px; -} - -.section-body::-webkit-scrollbar-thumb { - background: var(--info-panel-accent-color, #74b9ff); - border-radius: 2px; - opacity: 0.6; -} - -.section-body::-webkit-scrollbar-thumb:hover { +.info-row:hover .copy-btn, +.panel-header:hover .copy-btn, +.copy-btn:focus-visible { opacity: 1; } -@keyframes pulse { - - 0%, - 100% { +@media (hover: none) { + .copy-btn { opacity: 1; } - - 50% { - opacity: 0.7; - } } -.pulse { - animation: pulse 2s infinite; +/* Long values show a few lines until expanded */ +.info-value.is-clamped { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; } -/* Focus Node Button Styling - Premium Glass */ -.focus-node-btn { - display: inline-flex; - align-items: center; - gap: 6px; +.info-row.is-expanded .info-value.is-clamped { + display: block; +} - /* Ghost Style Default */ +.value-expand { + grid-area: 2 / 2; + justify-self: start; + padding: 0; + border: 0; background: transparent; - color: var(--info-panel-accent-color, #74b9ff); - border: 1px solid var(--info-panel-accent-color, #74b9ff); - - padding: 2px 10px; - border-radius: 6px; - font-weight: 600; - font-size: 0.9em; - letter-spacing: 0.02em; - - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 0 0 rgba(0, 0, 0, 0); - opacity: 0.9; -} - -.focus-node-btn:hover { - /* Glow only - no background fill to reduce animation load */ - background: transparent; - color: var(--info-panel-accent-color, #74b9ff); - - /* Subtle glow effect */ - box-shadow: 0 0 12px -2px var(--info-panel-accent-color, #74b9ff); - - opacity: 1; -} - -.focus-node-btn:active { - /* Subtle press feedback without scale */ - box-shadow: 0 1px 4px -2px var(--info-panel-accent-color, #74b9ff); - filter: brightness(0.92); -} - -/* High Contrast Text Mode */ -.magnify-info-panel.high-contrast-text, -.magnify-info-panel.high-contrast-text input, -.magnify-info-panel.high-contrast-text textarea, -.magnify-info-panel.high-contrast-text select, -.magnify-info-panel.high-contrast-text .info-value, -.magnify-info-panel.high-contrast-text .edit-input { - color: #ffffff !important; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8) !important; - font-weight: 600 !important; -} - -.magnify-info-panel.high-contrast-text .edit-input, -.magnify-info-panel.high-contrast-text textarea { - background: rgba(0, 0, 0, 0.6) !important; - border: 1px solid rgba(255, 255, 255, 0.4) !important; -} - -.magnify-info-panel.high-contrast-text ::placeholder { - color: rgba(255, 255, 255, 0.7) !important; -} - -.focus-node-btn .icon { - display: flex; - align-items: center; - transition: transform 0.2s ease; -} - -/* Icon remains stable on hover - no transform needed */ - -/* Ensure the SVG inside inherits color properly */ -.focus-node-btn svg { - stroke: currentColor; - width: 14px; - height: 14px; - stroke-width: 2.5; - /* Make icon slightly bolder */ -} - -/* ========================================================================== - Widget Editor Styles - Inspector Inline Editing - ========================================================================== */ - -/* Base widget editor container */ -.widget-editor { - display: flex; - align-items: center; - gap: 4px; - width: 100%; - box-sizing: border-box; -} - -/* Shared input styles */ -.widget-editor-input, -.widget-editor-textarea, -.widget-editor-select { - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 13px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--info-panel-accent-color, #74b9ff); - border-radius: 4px; - color: inherit; - padding: 6px 8px; - outline: none; - transition: all 0.2s ease; - box-sizing: border-box; -} - -.widget-editor-input:focus, -.widget-editor-textarea:focus, -.widget-editor-select:focus { - border-color: var(--info-panel-accent-color, #74b9ff); - box-shadow: 0 0 0 2px rgba(116, 185, 255, 0.25); - background: rgba(0, 0, 0, 0.4); -} - -.widget-editor-input:hover, -.widget-editor-textarea:hover, -.widget-editor-select:hover { - border-color: rgba(116, 185, 255, 0.7); -} - -/* Number editor specific */ -.widget-editor-number { - display: flex; - align-items: center; - gap: 2px; -} - -.widget-editor-number .widget-editor-input { - flex: 1; - min-width: 60px; - max-width: 100px; - text-align: center; - appearance: textfield; - -moz-appearance: textfield; -} - -.widget-editor-number .widget-editor-input::-webkit-outer-spin-button, -.widget-editor-number .widget-editor-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.widget-editor-stepper { - width: 24px; - height: 28px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.1); - color: inherit; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - font-weight: bold; - transition: all 0.15s ease; - border-radius: 4px; -} - -.widget-editor-stepper:hover { - background: rgba(255, 255, 255, 0.2); - border-color: var(--info-panel-accent-color, #74b9ff); -} - -.widget-editor-stepper:active { - background: rgba(255, 255, 255, 0.3); - transform: scale(0.95); -} - -.widget-editor-stepper.decrement { - border-radius: 4px 0 0 4px; -} - -.widget-editor-stepper.increment { - border-radius: 0 4px 4px 0; -} - -/* Text editor specific */ -.widget-editor-text { - flex-direction: column; - align-items: stretch; - gap: 4px; -} - -.widget-editor-text .widget-editor-input { - width: 100%; -} - -.widget-editor-textarea { - width: 100%; - min-height: 60px; - max-height: 200px; - resize: vertical; - line-height: 1.4; -} - -.widget-editor-counter { - font-size: 11px; - opacity: 0.6; - text-align: right; - padding-right: 4px; -} - -/* Combo/dropdown editor */ -.widget-editor-combo { - width: 100%; -} - -.widget-editor-select { - width: 100%; - cursor: pointer; - appearance: none; - /* Match the info-value row styling (green theme) */ - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 14px; - color: var(--info-panel-accent-color, #a0d468); - background: rgba(160, 212, 104, 0.12); - border: 1px solid transparent; - padding: 4px 28px 4px 10px; - border-radius: 6px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; - /* Custom dropdown arrow in matching green color */ - background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a0d468' 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: right 8px center; - background-size: 12px; -} - -.widget-editor-select:hover { - background: rgba(160, 212, 104, 0.18); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - border-color: transparent; -} - -.widget-editor-select:focus { - background: rgba(160, 212, 104, 0.22); - border-color: var(--info-panel-accent-color, #a0d468); - box-shadow: 0 0 0 2px rgba(160, 212, 104, 0.25); -} - -.widget-editor-select option { - background: #1a1a1f; - color: #a0d468; - padding: 8px; -} - -/* Boolean toggle editor */ -.widget-editor-boolean { - justify-content: flex-start; -} - -.widget-editor-toggle { - position: relative; - display: inline-block; - width: 44px; - height: 24px; + color: var(--mg-accent-fg); + font: inherit; + font-size: 0.85em; cursor: pointer; } -.widget-editor-checkbox { - opacity: 0; - width: 0; - height: 0; +.value-expand:hover { + text-decoration: underline; } -.widget-editor-toggle-slider { - position: absolute; - cursor: pointer; +.info-row.editing :is(.copy-btn, .value-expand) { + display: none; +} + +/* Off screen while the clipboard fallback copies from it */ +.copy-fallback { + position: fixed; top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(255, 255, 255, 0.2); - border-radius: 24px; - transition: all 0.3s ease; - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.widget-editor-toggle-slider::before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 2px; - bottom: 2px; - background: white; - border-radius: 50%; - transition: all 0.3s ease; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -.widget-editor-checkbox:checked+.widget-editor-toggle-slider { - background: var(--info-panel-accent-color, #74b9ff); - border-color: var(--info-panel-accent-color, #74b9ff); -} - -.widget-editor-checkbox:checked+.widget-editor-toggle-slider::before { - transform: translateX(20px); -} - -.widget-editor-checkbox:focus+.widget-editor-toggle-slider { - box-shadow: 0 0 0 2px rgba(116, 185, 255, 0.25); -} - -/* Slider editor */ -.widget-editor-slider { - gap: 8px; -} - -.widget-editor-range { - flex: 1; - height: 6px; - -webkit-appearance: none; - appearance: none; - background: rgba(255, 255, 255, 0.2); - border-radius: 3px; - outline: none; - cursor: pointer; -} - -.widget-editor-range::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--info-panel-accent-color, #74b9ff); - cursor: pointer; - border: 2px solid white; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - transition: all 0.15s ease; -} - -.widget-editor-range::-webkit-slider-thumb:hover { - transform: scale(1.1); - box-shadow: 0 0 0 4px rgba(116, 185, 255, 0.25); -} - -.widget-editor-range::-moz-range-thumb { - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--info-panel-accent-color, #74b9ff); - cursor: pointer; - border: 2px solid white; -} - -.widget-editor-slider-value { - min-width: 50px; - text-align: center; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 12px; - color: var(--info-panel-accent-color, #74b9ff); - background: rgba(0, 0, 0, 0.2); - padding: 4px 8px; - border-radius: 4px; -} - -/* Editable row styling */ -.info-row.editable { - cursor: pointer; -} - -.info-row.editable:hover { - background: rgba(116, 185, 255, 0.08); + left: -9999px; + opacity: 0; } .info-row.editable .info-value { cursor: text; - border: 1px solid transparent; } -.info-row.editable .info-value:hover { - border-color: rgba(116, 185, 255, 0.3); +.info-row.editable:hover, +.info-row.dragging { + background: var(--mg-control); } -.info-row.editing { - background: rgba(116, 185, 255, 0.1); - padding: 8px; - margin: 4px -8px; - border-radius: 6px; +.info-row.draggable { + cursor: ew-resize; } -.info-row.editing .info-label { - margin-bottom: 4px; +.info-row.dragging .info-value { + color: var(--mg-accent-fg); } -/* Hide original value when editing */ -.info-row.editing .info-value.original { - display: none; +.info-row.editing .widget-editor-container { + grid-column: 2 / -1; } -/* Copy button styling (for coexistence with edit) */ -.copy-btn { - flex-shrink: 0; - width: 24px; - height: 24px; - border: none; - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; +/* Text needs the room, so its editor goes under the label */ +.info-row.editing:has(.widget-editor-text) { + grid-template-columns: minmax(0, 1fr); + row-gap: 0.25em; +} + +.info-row.editing:has(.widget-editor-text) > * { + grid-area: auto; +} + +.edit-hint { + margin: 0.25em 0.75em 0; + padding: 0.125em 0.5em; + border: 1px solid var(--mg-stroke); + border-radius: 0.25rem; + background: transparent; + color: var(--mg-muted); + font-size: 0.85em; cursor: pointer; +} + +.edit-hint:hover { + color: var(--mg-fg); + background: var(--mg-control-hover); +} + +.linked-tag { + margin-left: 0.375em; + padding: 0 0.25em; + border: 1px solid var(--mg-stroke); + border-radius: 0.25rem; + color: var(--mg-muted); + font-size: 0.8em; +} + +/* Widget editors */ +.widget-editor { display: flex; align-items: center; - justify-content: center; - transition: all 0.15s ease; - margin-left: 4px; + gap: 0.25em; + width: 100%; } -.copy-btn svg { - width: 14px; - height: 14px; - stroke: currentColor; - opacity: 0.6; +.widget-editor-text { + flex-direction: column; + align-items: stretch; } -.copy-btn:hover { - background: rgba(255, 255, 255, 0.2); +.widget-editor-input, +.widget-editor-textarea { + width: 100%; + min-width: 0; + padding: 0.25em 0.5em; + border: 1px solid var(--mg-stroke); + border-radius: 0.375rem; + background: var(--mg-control); } -.copy-btn:hover svg { - opacity: 1; +.magnify-info-panel :is(.widget-editor-input, .widget-editor-textarea):focus-visible { + outline: none; + border-color: var(--mg-accent); } -.copy-btn.copied { - background: rgba(34, 197, 94, 0.2); +.widget-editor-number .widget-editor-input { + text-align: center; + font-variant-numeric: tabular-nums; + appearance: textfield; } -/* Theme-specific adjustments for editors */ -.magnify-info-panel.theme-light .widget-editor-input, -.magnify-info-panel.theme-light .widget-editor-textarea { - background: rgba(0, 0, 0, 0.05); - border-color: rgba(0, 0, 0, 0.2); +.widget-editor-number .widget-editor-input::-webkit-outer-spin-button, +.widget-editor-number .widget-editor-input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; } -.magnify-info-panel.theme-light .widget-editor-input:focus, -.magnify-info-panel.theme-light .widget-editor-textarea:focus { - background: rgba(0, 0, 0, 0.08); +.widget-editor-stepper { + width: 1.75em; + height: 1.75em; + background: var(--mg-control); } -.magnify-info-panel.theme-light .widget-editor-select { - background: transparent; - border-color: transparent; - color: var(--info-panel-accent-color, #0066cc); +.widget-editor-textarea { + min-height: 4.5em; + max-height: 15em; + resize: vertical; } -.magnify-info-panel.theme-light .widget-editor-select:hover { - background-color: rgba(0, 102, 204, 0.08); +.widget-editor-counter { + color: var(--mg-muted); + font-size: 0.875em; + font-variant-numeric: tabular-nums; + text-align: right; } -.magnify-info-panel.theme-light .widget-editor-select option { - background: #ffffff; - color: var(--info-panel-accent-color, #0066cc); +.widget-editor-range { + flex: 1; + min-width: 0; + margin: 0; + accent-color: var(--mg-accent); + cursor: pointer; } -.magnify-info-panel.theme-light .widget-editor-stepper { - border-color: rgba(0, 0, 0, 0.15); - background: rgba(0, 0, 0, 0.05); +.widget-editor-slider-value { + min-width: 3em; + font-variant-numeric: tabular-nums; + text-align: right; } -.magnify-info-panel.theme-light .widget-editor-toggle-slider { - background: rgba(0, 0, 0, 0.15); - border-color: rgba(0, 0, 0, 0.2); -} - -.magnify-info-panel.theme-github .widget-editor-input, -.magnify-info-panel.theme-github .widget-editor-textarea { - background: #f6f8fa; - border-color: #d1d5db; - color: #24292e; -} - -.magnify-info-panel.theme-github .widget-editor-select { - background: transparent; - border-color: transparent; - color: var(--info-panel-accent-color, #0366d6); -} - -.magnify-info-panel.theme-github .widget-editor-select:hover { - background-color: rgba(3, 102, 214, 0.08); -} - -.magnify-info-panel.theme-github .widget-editor-select option { - background: #ffffff; - color: var(--info-panel-accent-color, #0366d6); -} - -/* Milk White Theme - light editor adjustments */ -.magnify-info-panel.theme-milk-white .widget-editor-input, -.magnify-info-panel.theme-milk-white .widget-editor-textarea { - background: rgba(255, 255, 255, 0.8); - border-color: rgba(180, 175, 170, 0.4); - color: #3d3d3d; -} - -.magnify-info-panel.theme-milk-white .widget-editor-input:focus, -.magnify-info-panel.theme-milk-white .widget-editor-textarea:focus { - border-color: var(--info-panel-accent-color, #7c3aed); -} - -.magnify-info-panel.theme-milk-white .widget-editor-select { - background: transparent; - border-color: transparent; - color: var(--info-panel-accent-color, #7c3aed); -} - -.magnify-info-panel.theme-milk-white .widget-editor-select:hover { - background-color: rgba(124, 58, 237, 0.08); -} - -.magnify-info-panel.theme-milk-white .widget-editor-select option { - background: #fffdfb; - color: var(--info-panel-accent-color, #7c3aed); -} - -.magnify-info-panel.theme-milk-white .widget-editor-stepper { - border-color: rgba(0, 0, 0, 0.12); - background: rgba(0, 0, 0, 0.03); -} - -.magnify-info-panel.theme-milk-white .widget-editor-toggle-slider { - background: rgba(0, 0, 0, 0.12); - border-color: rgba(0, 0, 0, 0.15); -} - -.magnify-info-panel.theme-milk-white .inline-toggle-slider { - background: rgba(0, 0, 0, 0.12); -} - -.magnify-info-panel.theme-milk-white .inline-dropdown-select { - background: transparent; - border-color: transparent; - color: var(--info-panel-accent-color, #7c3aed); -} - -.magnify-info-panel.theme-milk-white .inline-dropdown-select:hover { - background-color: rgba(124, 58, 237, 0.08); -} - -.magnify-info-panel.theme-milk-white .inline-dropdown-select option { - background: #fffdfb; - color: var(--info-panel-accent-color, #7c3aed); -} - -.magnify-info-panel.theme-milk-white .inline-action-button { - background: var(--info-panel-accent-color, #7c3aed); - color: #ffffff; -} - -/* ========================================================================== - Inline Controls - Compact controls that render directly in rows - ========================================================================== */ - +/* Inline controls */ .inline-control-container { display: flex; align-items: center; - margin-left: auto; } -.inline-control { - display: inline-flex; - align-items: center; +.inline-dropdown { + flex: 1; + min-width: 0; } -/* Inline Toggle - Compact version */ +/* Toggles, as the sidebar draws them */ .inline-toggle-switch { position: relative; - display: inline-block; - width: 36px; - height: 20px; + display: block; + flex: none; + width: 2.25em; + height: 1.25em; cursor: pointer; } .inline-toggle-input { - opacity: 0; + position: absolute; width: 0; height: 0; + opacity: 0; } .inline-toggle-slider { position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(255, 255, 255, 0.2); - border-radius: 20px; - transition: all 0.25s ease; - border: 1px solid rgba(255, 255, 255, 0.25); + inset: 0; + border-radius: 1em; + background: var(--border-default, var(--mg-stroke)); + transition: background-color 120ms var(--mg-ease); } .inline-toggle-slider::before { - position: absolute; - content: ""; - height: 14px; - width: 14px; - left: 2px; - bottom: 2px; - background: white; - border-radius: 50%; - transition: all 0.25s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); -} - -.inline-toggle-input:checked+.inline-toggle-slider { - background: var(--info-panel-accent-color, #74b9ff); - border-color: var(--info-panel-accent-color, #74b9ff); -} - -.inline-toggle-input:checked+.inline-toggle-slider::before { - transform: translateX(16px); -} - -.inline-toggle-input:focus+.inline-toggle-slider { - box-shadow: 0 0 0 2px rgba(116, 185, 255, 0.25); -} - -/* Inline Dropdown - Compact version matching info-value theme (green bg, blue text) */ -.inline-dropdown { - min-width: 100px; - max-width: 180px; -} - -.inline-dropdown-select { - width: 100%; - padding: 4px 24px 4px 10px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 14px; - /* Match info-value style: green background with blue text */ - background: rgba(160, 212, 104, 0.12); - border: 1px solid transparent; - border-radius: 6px; - color: var(--info-panel-accent-color, #74b9ff); - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2374b9ff' 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: right 6px center; - background-size: 12px; - transition: all 0.2s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.inline-dropdown-select:hover { - background-color: rgba(160, 212, 104, 0.18); - border-color: transparent; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -.inline-dropdown-select:focus { - outline: none; - background-color: rgba(160, 212, 104, 0.2); - border-color: var(--info-panel-accent-color, #74b9ff); - box-shadow: 0 0 0 2px rgba(116, 185, 255, 0.2); -} - -.inline-dropdown-select option { - background: #1a1a1f; - color: var(--info-panel-accent-color, #74b9ff); - padding: 4px; -} - -/* ========================================================================== - Drag Gesture Indicators - ========================================================================== */ - -.info-row.draggable { - position: relative; - cursor: ew-resize; - user-select: none; - transition: none; -} - -/* Value container in draggable rows should show text cursor for click-to-edit */ -.info-row.draggable .info-value { - cursor: text; - position: relative; - z-index: 1; -} - -.info-row.draggable .info-value:hover { - background: rgba(116, 185, 255, 0.18); - border-color: rgba(116, 185, 255, 0.4); -} - -.info-row.draggable::after { content: ''; position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 2px; - background: linear-gradient(90deg, - transparent 0%, - rgba(116, 185, 255, 0.3) 20%, - rgba(116, 185, 255, 0.3) 80%, - transparent 100%); - opacity: 0; + 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); } -.info-row.draggable:hover::after { - opacity: 1; +:checked + .inline-toggle-slider { + background: var(--mg-accent); } -.info-row.dragging { - background: rgba(116, 185, 255, 0.1) !important; +:checked + .inline-toggle-slider::before { + transform: translateX(1em); } -.info-row.dragging .info-value { - color: var(--info-panel-accent-color, #74b9ff); - font-weight: 600; +:focus-visible + .inline-toggle-slider { + outline: 2px solid var(--mg-ring); + outline-offset: 2px; } -.drag-indicator { - position: absolute; - right: 4px; - top: 50%; - transform: translateY(-50%); - font-size: 10px; - opacity: 0; - color: rgba(255, 255, 255, 0.4); - pointer-events: none; -} - -.info-row.draggable:hover .drag-indicator { - opacity: 0.6; -} - -.info-row.dragging .drag-indicator { - opacity: 1; - color: var(--info-panel-accent-color, #74b9ff); -} - -/* Theme-specific adjustments for inline controls */ -.magnify-info-panel.theme-light .inline-toggle-slider { - background: rgba(0, 0, 0, 0.15); - border-color: rgba(0, 0, 0, 0.2); -} - -.magnify-info-panel.theme-light .inline-dropdown-select { - background: transparent; - border-color: transparent; - color: var(--info-panel-accent-color, #0066cc); -} - -.magnify-info-panel.theme-light .inline-dropdown-select:hover { - background-color: rgba(0, 102, 204, 0.08); -} - -.magnify-info-panel.theme-light .inline-dropdown-select option { - background: #ffffff; - color: var(--info-panel-accent-color, #0066cc); -} - -.magnify-info-panel.theme-github .inline-dropdown-select { - background: transparent; - border-color: transparent; - color: var(--info-panel-accent-color, #0366d6); -} - -.magnify-info-panel.theme-github .inline-dropdown-select:hover { - background-color: rgba(3, 102, 214, 0.08); -} - -.magnify-info-panel.theme-github .inline-dropdown-select option { - background: #ffffff; - color: var(--info-panel-accent-color, #0366d6); -} - -/* ========================================================================== - Inline Button Control - ========================================================================== */ - -.inline-control.inline-button { +/* Dropdown triggers */ +.inline-dropdown-trigger { display: flex; align-items: center; + gap: 0.375em; + min-height: 1.75em; + padding: 0 0.5em; + border-radius: 0.375rem; + background: var(--mg-control); + cursor: pointer; +} + +.inline-dropdown-trigger:hover { + background: var(--mg-control-hover); +} + +.custom-dropdown-value { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.custom-dropdown-arrow { + display: flex; + color: var(--mg-muted); } .inline-action-button { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 13px; - background: var(--info-panel-accent-color, #a0d468); - color: var(--comfy-menu-bg, #1a1a1a); - border: none; - border-radius: 6px; - cursor: pointer; + min-height: 1.75em; + padding: 0 0.75em; + border: 0; + border-radius: 0.375rem; + background: var(--mg-accent); + color: var(--base-foreground, var(--mg-fg)); font-weight: 600; - transition: all 0.2s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + cursor: pointer; } .inline-action-button:hover { - transform: translateY(-1px); - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25); + background: color-mix(in srgb, var(--mg-accent) 85%, var(--mg-fg)); } -.inline-action-button:active { - transform: translateY(0); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +/* Menus appended to body: the shared dropdown and the Go to node picker */ +.custom-dropdown-menu, +.node-selector-dropdown { + position: fixed; + display: flex; + flex-direction: column; + box-sizing: border-box; + color: var(--mg-fg); + background: var(--mg-surface); + border: 1px solid var(--mg-stroke); + border-radius: 0.5rem; + box-shadow: var(--mg-shadow); + font-size: 0.8125rem; + line-height: 1.35; } -/* Light theme adjustments */ -.magnify-info-panel.theme-light .inline-action-button { - background: var(--info-panel-accent-color, #0066cc); - color: #ffffff; +.custom-dropdown-menu { + z-index: 100001; + overflow: hidden; } -.magnify-info-panel.theme-github .inline-action-button { - background: var(--info-panel-accent-color, #0366d6); - color: #ffffff; -} \ No newline at end of file +.custom-dropdown-filter, +.node-picker-filter { + margin: 0.25rem; + padding: 0.25rem 0.5rem; + font: inherit; + color: inherit; + background: var(--mg-control); + border: 1px solid var(--mg-stroke); + border-radius: 0.375rem; +} + +.custom-dropdown-list, +.node-picker-list { + min-height: 0; + overflow-y: auto; +} + +.custom-dropdown-item { + padding: 0.375rem 0.625rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; +} + +.custom-dropdown-item.active, +.node-selector-dropdown .dropdown-item.active { + background: var(--mg-control-hover); +} + +.custom-dropdown-item[aria-selected="true"], +.node-selector-dropdown .dropdown-item[aria-selected="true"] { + font-weight: 600; + box-shadow: inset 2px 0 var(--mg-accent); +} + +.node-selector-dropdown { + z-index: 100000; + max-width: calc(100vw - 20px); + overflow: hidden; +} + +.node-selector-dropdown .dropdown-item { + display: flex; + flex: none; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + cursor: pointer; +} + +.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.85em; +} + +.dropdown-item-type { + flex-shrink: 1; + max-width: 40%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.dropdown-item-id { + font-variant-numeric: tabular-nums; +} + +.node-picker-empty { + padding: 0.5rem 0.75rem; + color: var(--mg-muted); +} + +/* Highlight around the inspected node on the canvas */ +#magnify-glass-node-highlight { + position: fixed; + z-index: 1000; + box-sizing: border-box; + border: 2px solid var(--mg-accent); + border-radius: 10px; + box-shadow: 0 0 10px color-mix(in srgb, var(--mg-accent) 30%, transparent); + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .magnify-info-panel *, + .floating-controls * { + transition: none !important; + } +} diff --git a/package.json b/package.json index a5eee43..1db5959 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "comfyui-magnifyglass", - "version": "1.14.0", + "version": "1.15.0", "type": "module", "description": "A professional magnifying glass and info panel for ComfyUI", "scripts": { diff --git a/pyproject.toml b/pyproject.toml index 7e4b3f4..b19893f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-magnifyglass" description = "ComfyUI-MagnifyGlass: A powerful & customizable magnifying glass for ComfyUI. Zoom into canvas details with smooth controls, configurable activation, custom styles (shape, size, border) & WebGL performance." -version = "1.14.0" +version = "1.15.0" license = {file = "LICENSE"} [project.urls] diff --git a/src/info-panel/CanvasHighlighter.ts b/src/info-panel/CanvasHighlighter.ts index 64b8dbb..d06d24a 100644 --- a/src/info-panel/CanvasHighlighter.ts +++ b/src/info-panel/CanvasHighlighter.ts @@ -6,7 +6,6 @@ */ import { ComfyApp, ComfyNode } from '../types/comfyui'; -import { getSettingValue } from '../shared/utils'; declare const app: ComfyApp; @@ -17,8 +16,11 @@ export class CanvasHighlighter { private highlightEl: HTMLDivElement | null = null; // Cache for DOM updates to minimize thrashing private lastStyleState: string = ''; + // The panel's settings cache, which the setting's onChange keeps current + private settings: { [key: string]: string | number | boolean }; - constructor() { + constructor(settings: { [key: string]: string | number | boolean }) { + this.settings = settings; this.createHighlightElement(); // Don't hook immediately, will hook lazily when needed } @@ -26,42 +28,10 @@ export class CanvasHighlighter { private createHighlightElement(): void { this.highlightEl = document.createElement("div"); this.highlightEl.id = "magnify-glass-node-highlight"; - this.highlightEl.style.cssText = ` - position: fixed; - pointer-events: none; - border: 2px solid #007bff; - border-radius: 10px; - z-index: 1000; - display: none; - box-sizing: border-box; - box-shadow: 0 0 10px rgba(0, 123, 255, 0.3); - `; + this.highlightEl.style.display = 'none'; document.body.appendChild(this.highlightEl); } - /** - * Hook into the main canvas onDrawForeground method. - */ - private hookCanvas(): boolean { - const app = (window as any).app; - if (!app || !app.canvas) { - return false; - } - const canvas = app.canvas; - - // Already hooked? - if (canvas.onDrawForeground === this.boundOnDrawForeground) { - return true; - } - - // Save original method - this.originalOnDrawForeground = canvas.onDrawForeground; - - // Override - canvas.onDrawForeground = this.boundOnDrawForeground; - return true; - } - /** * Set the node ID to highlight. */ @@ -114,11 +84,10 @@ export class CanvasHighlighter { private updateHighlightPosition(): void { if (!this.highlightEl) return; const app = (window as any).app; - if (!app || !app.canvas || !app.graph) return; + if (!app?.canvas?.graph) return; // 1. Check Visibility Settings - const highlightEnabled = getSettingValue('🔍MagnifyGlass.NodeHighlightEnabled', true); - if (!highlightEnabled || this.highlightedNodeId === null) { + if (!this.settings["🔍MagnifyGlass.NodeHighlightEnabled"] || this.highlightedNodeId === null) { if (this.highlightEl.style.display !== 'none') { this.highlightEl.style.display = 'none'; } @@ -126,7 +95,7 @@ export class CanvasHighlighter { } // 2. Get Node - const node = app.graph.getNodeById(this.highlightedNodeId); + const node = app.canvas.graph.getNodeById(this.highlightedNodeId); if (!node) { if (this.highlightEl.style.display !== 'none') { this.highlightEl.style.display = 'none'; @@ -169,14 +138,16 @@ export class CanvasHighlighter { const finalW = canvasW + (padding * 2); const finalH = canvasH + (padding * 2); - // 5. Update DOM - const styleState = `${pageX.toFixed(1)},${pageY.toFixed(1)},${finalW.toFixed(1)},${finalH.toFixed(1)}`; + // 5. Update DOM, clipped to the canvas element (negative insets keep the glow inside it) + const clip = `inset(${canvasRect.top - pageY}px ${pageX + finalW - canvasRect.right}px ${pageY + finalH - canvasRect.bottom}px ${canvasRect.left - pageX}px)`; + const styleState = `${pageX.toFixed(1)},${pageY.toFixed(1)},${finalW.toFixed(1)},${finalH.toFixed(1)},${clip}`; if (this.lastStyleState !== styleState || this.highlightEl.style.display === 'none') { this.highlightEl.style.display = 'block'; this.highlightEl.style.left = `${pageX}px`; this.highlightEl.style.top = `${pageY}px`; this.highlightEl.style.width = `${finalW}px`; this.highlightEl.style.height = `${finalH}px`; + this.highlightEl.style.clipPath = clip; // Border width scales with zoom (clamped 2-6px) const borderWidth = Math.max(2, Math.min(6, 3 * scale)); diff --git a/src/info-panel/EventManager.ts b/src/info-panel/EventManager.ts index fc0b27a..087d854 100644 --- a/src/info-panel/EventManager.ts +++ b/src/info-panel/EventManager.ts @@ -6,15 +6,16 @@ */ import { StateManager } from './StateManager'; -import { UIManager } from './UIManager'; -import { PositionManager } from './PositionManager'; -import { isUserTyping } from '../shared/utils'; -import { Logger } from '../shared/logger'; +import { isUserTyping, getSettingValue, setSettingValue, clamp } from '../shared/utils'; +import { DEFAULT_PANEL_SETTINGS, DEFAULT_GLASS_SETTINGS } from '../shared/settings/defaults'; export interface EventCallbacks { toggleVisibility: () => void; - togglePin: () => void; - updatePosition: () => void; + toggleFloat: () => void; + toggleSticky: () => void; + /** Apply a dragged position or size: styles, toolbar states and positions */ + updateLayout: () => void; + isGlassActive: () => boolean; } /** @@ -24,155 +25,99 @@ export interface EventCallbacks { export class EventManager { stateManager: StateManager; panelElement: HTMLElement | null; - positionManager: PositionManager; callbacks: EventCallbacks | null; dragCleanup: (() => void) | null; - boundHandleMouseMove: (e: MouseEvent) => void; boundHandleKeyDown: (e: KeyboardEvent) => void; - boundHandleKeyUp: (e: KeyboardEvent) => void; - constructor(stateManager: StateManager, panelElement: HTMLElement | null, positionManager: PositionManager, callbacks?: EventCallbacks) { + constructor(stateManager: StateManager, panelElement: HTMLElement | null, callbacks?: EventCallbacks) { this.stateManager = stateManager; this.panelElement = panelElement; - this.positionManager = positionManager; this.callbacks = callbacks || null; this.dragCleanup = null; - this.boundHandleMouseMove = this.handleMouseMove.bind(this); this.boundHandleKeyDown = this.handleKeyDown.bind(this); - this.boundHandleKeyUp = this.handleKeyUp.bind(this); this.setupEventListeners(); } setupEventListeners(): void { - this.setupPanelEvents(); this.setupHotkeyEvents(); this.setupDragEvents(); this.setupHoverEvents(); } - setupPanelEvents(): void { - if (!this.panelElement) return; - - // Vue handles most internal events now. - // We can keep specific global-ish listeners if needed here. - } - setupHotkeyEvents(): void { document.addEventListener('keydown', this.boundHandleKeyDown); } + /** + * Dragging the header floats the panel; the corner handle resizes it into the Width and Max Height settings. + * A press that does not move is a click, so it leaves the panel where it is. + */ setupDragEvents(): void { - if (!this.panelElement) return; + const panel = this.panelElement; + if (!panel) return; - let isDragging = false; - let rafId: number | null = null; - let targetX = 0; - let targetY = 0; - - const startDrag = (e: MouseEvent) => { - if (!this.panelElement) return; + const startDrag = (e: PointerEvent) => { const target = e.target as HTMLElement; - - // Only allow dragging from header (expand clickable area) - const header = target.closest('.panel-header'); - if (!header) return; - if (target.closest('button')) return; - - // Button 2 (pin icon, data-action="lock") prevents dragging - if (this.stateManager.state.isPanelLocked) return; - - // Also prevent dragging if we are "Following Glass" (not pinned to screen) - // This prevents fighting with the auto-positioner and accidental drags - if (!this.stateManager.state.isPanelPinned) return; - + const resizing = !!target.closest('.panel-resize'); + if (e.button !== 0 || (!resizing && (!target.closest('.panel-header') || target.closest('button')))) return; e.preventDefault(); - e.stopPropagation(); - - isDragging = true; + const state = this.stateManager.state; + const settings = state.settings; const startX = e.clientX; const startY = e.clientY; - const rect = this.panelElement.getBoundingClientRect(); - const startLeft = rect.left; - const startTop = rect.top; + const rect = panel.getBoundingClientRect(); + const content = panel.querySelector('.panel-content') as HTMLElement; + const startContentHeight = content.offsetHeight; + // Max Height shows only while the content is at it + const capped = content.scrollHeight > startContentHeight; + const startWidth = settings["🔍MagnifyGlass.InfoPanelWidth"]; + const startMaxHeight = settings["🔍MagnifyGlass.InfoPanelMaxHeight"]; + let moved = false; - // Visual feedback - dragging state - this.panelElement.style.cursor = 'grabbing'; - this.panelElement.style.opacity = '0.9'; - this.panelElement.style.transition = 'none'; // Disable transitions during drag - document.body.style.cursor = 'grabbing'; - document.body.style.userSelect = 'none'; + const onPointerMove = (move: PointerEvent) => { + const dx = move.clientX - startX; + const dy = move.clientY - startY; + if (!moved && Math.abs(dx) + Math.abs(dy) < 3) return; + moved = true; + if (resizing) { + // An axis that moves less than a drag keeps its setting, so a sideways drag keeps Max Height + settings["🔍MagnifyGlass.InfoPanelWidth"] = Math.abs(dx) >= 3 ? clamp(Math.round(rect.width + dx), 200, 600) : startWidth; + settings["🔍MagnifyGlass.InfoPanelMaxHeight"] = capped && Math.abs(dy) >= 3 ? clamp(Math.round(startContentHeight + dy), 200, 1500) : startMaxHeight; + } else { + state.isFloating = true; + state.floatPosition = { x: rect.left + dx, y: rect.top + dy }; + } + this.callbacks?.updateLayout(); + }; - const onMouseMove = (moveEvent: MouseEvent) => { - if (!isDragging) return; - - const dx = moveEvent.clientX - startX; - const dy = moveEvent.clientY - startY; - - targetX = startLeft + dx; - targetY = startTop + dy; - - // Keep within viewport bounds - const panelWidth = this.panelElement?.offsetWidth || 0; - const panelHeight = this.panelElement?.offsetHeight || 0; - targetX = Math.max(0, Math.min(targetX, window.innerWidth - panelWidth)); - targetY = Math.max(0, Math.min(targetY, window.innerHeight - panelHeight)); - - // Use RAF for smooth updates - if (rafId === null) { - rafId = requestAnimationFrame(() => { - if (this.panelElement && isDragging) { - this.panelElement.style.left = `${targetX}px`; - this.panelElement.style.top = `${targetY}px`; - } - rafId = null; - }); + const onPointerUp = () => { + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('pointercancel', onPointerUp); + panel.classList.remove('is-dragging'); + if (!moved) return; + if (resizing) { + for (const [id, start] of [["🔍MagnifyGlass.InfoPanelWidth", startWidth], ["🔍MagnifyGlass.InfoPanelMaxHeight", startMaxHeight]] as const) { + if (settings[id] !== start) setSettingValue(id, settings[id]); + } + } else { + this.stateManager.saveLayout(); } }; - const onMouseUp = () => { - isDragging = false; - - // Cancel any pending RAF - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - - // Restore visual state - if (this.panelElement) { - this.panelElement.style.cursor = ''; - this.panelElement.style.opacity = ''; - this.panelElement.style.transition = ''; - - // Final position update - this.panelElement.style.left = `${targetX}px`; - this.panelElement.style.top = `${targetY}px`; - - // Save pinned position - this.stateManager.state.pinnedPosition = { x: targetX, y: targetY }; - } - - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); + panel.classList.add('is-dragging'); + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + document.addEventListener('pointercancel', onPointerUp); }; - this.panelElement.addEventListener('mousedown', startDrag); - - this.dragCleanup = () => { - if (this.panelElement) this.panelElement.removeEventListener('mousedown', startDrag); - }; + panel.addEventListener('pointerdown', startDrag); + this.dragCleanup = () => panel.removeEventListener('pointerdown', startDrag); } setupHoverEvents(): void { @@ -180,88 +125,43 @@ export class EventManager { this.panelElement.addEventListener('mouseenter', () => { this.stateManager.state.isPanelHovered = true; - this.stateManager.clearAutoExpandTimer(); }); this.panelElement.addEventListener('mouseleave', () => { this.stateManager.state.isPanelHovered = false; - if (!this.stateManager.state.isHoveringNode) { - this.stateManager.scheduleAutoCollapse(); - } }); } + /** + * Panel hotkeys act only while the glass is active and never with Ctrl, Meta or Shift held. Alt must be + * held when AltRequired is on and must not be otherwise. Keys are read on each press, so changes apply at once. + */ handleKeyDown(e: KeyboardEvent): void { - if (isUserTyping()) return; + if (isUserTyping() || !this.callbacks?.isGlassActive()) return; + if (e.ctrlKey || e.metaKey || e.shiftKey) return; + const altRequired = getSettingValue("🔍MagnifyGlass.AltRequired", DEFAULT_GLASS_SETTINGS["🔍MagnifyGlass.AltRequired"]); + if (e.altKey !== altRequired) return; - const settings = this.stateManager.state.settings; const key = e.key.toLowerCase(); - // console.log(`[InfoPanel] KeyDown: ${key}`); + const hotkey = (id: keyof typeof DEFAULT_PANEL_SETTINGS & string) => String(getSettingValue(id, DEFAULT_PANEL_SETTINGS[id])).toLowerCase(); - // Toggle visibility - if (key === (settings["🔍MagnifyGlass.ToggleHotkey"] as string).toLowerCase()) { - Logger.debug('Toggle visibility hotkey matched!'); + if (key === hotkey("🔍MagnifyGlass.ToggleHotkey")) { e.preventDefault(); - this.callbacks?.toggleVisibility(); + this.callbacks.toggleVisibility(); } - // Toggle Pin - if (key === (settings["🔍MagnifyGlass.PinPanelHotkey"] as string).toLowerCase()) { + // Follow glass / Float + if (key === hotkey("🔍MagnifyGlass.PinPanelHotkey")) { e.preventDefault(); - this.callbacks?.togglePin(); + this.callbacks.toggleFloat(); } - // Toggle Hold Info (Pause/Play) - const holdInfoHotkey = (settings["🔍MagnifyGlass.HoldInfoHotkey"] as string || "p").toLowerCase(); - if (key === holdInfoHotkey) { - // Only works when Sticky Info (Persist) is enabled - if (settings["🔍MagnifyGlass.InfoPanelPersist"]) { - e.preventDefault(); - const isHeld = this.stateManager.toggleHold(); - Logger.debug(`Hold Info toggled: ${isHeld ? 'PAUSED' : 'PLAYING'}`); - // Update the hover controls UI - const infoPanel = (window as any).infoPanelManager; - if (infoPanel?.uiManager) { - infoPanel.uiManager.updateControlStates(); - } - } - } - - // Toggle Sticky Info (Persist mode) - const stickyInfoHotkey = (settings["🔍MagnifyGlass.StickyInfoHotkey"] as string || "s").toLowerCase(); - if (key === stickyInfoHotkey) { + if (key === hotkey("🔍MagnifyGlass.StickyInfoHotkey")) { e.preventDefault(); - const newValue = !(settings["🔍MagnifyGlass.InfoPanelPersist"] as boolean); - // Update local state - settings["🔍MagnifyGlass.InfoPanelPersist"] = newValue; - // Persist to ComfyUI settings - try { - const app = (window as any).app; - app.ui.settings.setSettingValue("🔍MagnifyGlass.InfoPanelPersist", newValue); - } catch (err) { - Logger.debug('Failed to persist sticky info setting'); - } - // If disabling sticky, also disable hold - if (!newValue) { - this.stateManager.state.isInfoHeld = false; - } - Logger.debug(`Sticky Info toggled: ${newValue ? 'ON' : 'OFF'}`); - // Update the hover controls UI - const infoPanel = (window as any).infoPanelManager; - if (infoPanel?.uiManager) { - infoPanel.uiManager.updateControlStates(); - } + this.callbacks.toggleSticky(); } } - handleMouseMove(e: MouseEvent): void { - // Placeholder - } - - handleKeyUp(e: KeyboardEvent): void { - // Placeholder - } - cleanup(): void { document.removeEventListener('keydown', this.boundHandleKeyDown); if (this.dragCleanup) this.dragCleanup(); diff --git a/src/info-panel/InfoPanel.ts b/src/info-panel/InfoPanel.ts index cefd2f8..76d93a6 100644 --- a/src/info-panel/InfoPanel.ts +++ b/src/info-panel/InfoPanel.ts @@ -30,6 +30,7 @@ export class InfoPanel { private originalShow: () => void; private originalHide: () => void; private updateFrameId: number | null = null; + private boundScheduleSelectionUpdate: () => void; constructor(magnifyGlass: MagnifyGlassInstance) { this.magnifyGlass = magnifyGlass; @@ -38,13 +39,13 @@ export class InfoPanel { this.stateManager = new StateManager(); this.uiManager = new UIManager(this.stateManager); this.informationGatherer = new InformationGatherer(); - this.canvasHighlighter = new CanvasHighlighter(); + this.canvasHighlighter = new CanvasHighlighter(this.stateManager.state.settings); // Position Manager needs the UI elements this.positionManager = new PositionManager(this.stateManager, this.uiManager.elements.panel!); // Event Manager needs state and UI manager - this.eventManager = new EventManager(this.stateManager, this.uiManager.elements.panel!, this.positionManager, { + this.eventManager = new EventManager(this.stateManager, this.uiManager.elements.panel!, { toggleVisibility: () => { if (this.stateManager.state.isPanelVisible) { this.uiManager.hide(); @@ -52,13 +53,26 @@ export class InfoPanel { this.uiManager.show(); } }, - togglePin: () => { - this.stateManager.togglePinning(); - this.uiManager.updatePinnedState(); + toggleFloat: () => this.uiManager.toggleFloating(), + toggleSticky: () => this.uiManager.setPersist(!this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]), + updateLayout: () => { + this.uiManager.applyStyles(); + this.uiManager.updateControlStates(); + this.positionManager.positionPanel(); + this.positionManager.positionFloatingControls(this.uiManager.elements.controls); }, - updatePosition: () => this.positionManager.positionPanel() + isGlassActive: () => this.magnifyGlass.state.active }); + // A click or key can change the selection without moving the mouse, which drives the other updates + this.boundScheduleSelectionUpdate = () => { + if (this.magnifyGlass.state.active && this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelFollowSelection"]) { + this.scheduleInfoUpdate(); + } + }; + document.addEventListener('pointerup', this.boundScheduleSelectionUpdate, true); + document.addEventListener('keyup', this.boundScheduleSelectionUpdate, true); + this.originalUpdateMagnifiedView = magnifyGlass.updateMagnifiedView; this.originalShow = magnifyGlass.ui.show; this.originalHide = magnifyGlass.ui.hide; @@ -69,21 +83,19 @@ export class InfoPanel { this.magnifyGlass.popOutManager.onStateChange = () => { this.uiManager.updateControlStates(); }; - // Listen for node selection from popout + // A node picked in the pop-out is a jump like any other this.magnifyGlass.popOutManager.onNodeSelect = (nodeId: number) => { - this.onNodeSelected(nodeId); + this.uiManager.jumpToNode(nodeId); }; // Listen for widget edits from popout to force refresh this.magnifyGlass.popOutManager.onWidgetEdit = () => { - // Force update to reflect changes (e.g. unhidden widgets) - this.updateInfo(true); + // Reflect the edit (e.g. unhidden widgets) + this.updateInfo(); }; } - // Set up node selection callback - this.uiManager.onNodeSelected = (nodeId: number) => { - this.onNodeSelected(nodeId); - }; + // Jumps, the lock and the mode buttons show their result at once + this.uiManager.onRefresh = () => this.updateInfo(); Logger.debug('Info Panel initialized successfully'); } @@ -142,14 +154,6 @@ export class InfoPanel { this.originalHide.call(mg.ui); this.uiManager.hide(); - // FORCE HIDE: Directly set panel display to none as a safety measure - if (this.uiManager.elements.panel) { - this.uiManager.elements.panel.style.display = 'none'; - this.uiManager.elements.panel.style.pointerEvents = 'none'; - // Also force opacity 0 to prevent any visual ghost - this.uiManager.elements.panel.style.opacity = '0'; - } - // Explicitly hide floating controls when glass is disabled if (this.uiManager.elements.controls) { this.uiManager.elements.controls.style.display = 'none'; @@ -190,21 +194,35 @@ export class InfoPanel { cancelAnimationFrame(this.updateFrameId); this.updateFrameId = null; } + document.removeEventListener('pointerup', this.boundScheduleSelectionUpdate, true); + document.removeEventListener('keyup', this.boundScheduleSelectionUpdate, true); this.eventManager.cleanup(); this.uiManager.cleanup(); this.canvasHighlighter.cleanup(); - this.stateManager.cleanup(); } - // State for persistence - private lastValidNodeInfo: any = null; + /** + * Follow the glass again from where it starts. + */ + resetPosition(): void { + // A hidden glass cannot be followed + if (!this.stateManager.state.isGlassPreviewVisible) return; + this.stateManager.state.isFloating = false; + this.stateManager.state.floatPosition = null; + this.stateManager.saveLayout(); + this.positionManager.positionPanel(); + this.uiManager.updateControlStates(); + } + + // The node Sticky keeps, looked up again on each update like the locked one + private stickyNodeId: number | null = null; + private stickyGraph: unknown = null; /** * Update the info panel with current information. - * @param forceUpdate - If true, bypass the isInfoHeld check to force an update */ - updateInfo(forceUpdate: boolean = false): void { + updateInfo(): void { const settings = this.stateManager.state.settings; const isActive = this.magnifyGlass.state.active; @@ -219,61 +237,41 @@ export class InfoPanel { return; } - // Logic for "Hold Info" (Freeze) - // Skip if held, UNLESS we're forcing an update (e.g., from dropdown selection) - if (this.stateManager.state.isInfoHeld && !forceUpdate) { - // If held, we skip gathering new info and skip updating the display content. - // But we MUST still update positioning to keep the UI responsive to movement. - this.positionManager.positionPanel(); - this.positionManager.positionFloatingControls(this.uiManager.elements.controls); - return; - } - let info = this.informationGatherer.gatherInformation(); + const state = this.stateManager.state; - // AUTO-CLEAR SELECTION: If user hovers a different node, clear the manual selection - // This allows the dropdown selection to be a "jump to" action, then resume normal hover - const selectedNodeId = this.stateManager.state.selectedNodeId; - if (selectedNodeId !== null && info.hoveredNode && info.hoveredNode.id !== selectedNodeId) { - // User is hovering a different node than the selected one - clear selection - this.stateManager.clearSelectedNode(); + // The locked node is shown whatever is hovered, then the first selected node when following the selection. + // The lock goes when its node does, or when another graph is on screen, where its id is another node. + if (state.lockedNodeId !== null && state.lockedGraph !== app.canvas?.graph) { + state.lockedNodeId = null; + } + const lockedNode = state.lockedNodeId === null ? null : this.uiManager.nodeSelector.getNodeById(state.lockedNodeId); + if (!lockedNode) { + state.lockedNodeId = null; + } + const selectedNode = !lockedNode && settings["🔍MagnifyGlass.InfoPanelFollowSelection"] + ? Object.values(app.canvas?.selected_nodes ?? {})[0] + : undefined; + state.showsSelection = !!selectedNode; + const shownNode = lockedNode ?? selectedNode; + if (shownNode) { + info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(shownNode) }; } - // NODE SELECTION OVERRIDE - check if user has selected a specific node (after auto-clear check) - const currentSelectedId = this.stateManager.state.selectedNodeId; - if (currentSelectedId !== null) { - const selectedNode = this.uiManager.nodeSelector.getNodeById(currentSelectedId); - if (selectedNode) { - // Override with selected node info - const detailedInfo = this.informationGatherer.getDetailedNodeInfo( - selectedNode, - { x: 0, y: 0 } // No local position when manually selected - ); - info = { - ...info, - hoveredNode: detailedInfo, - hoveredWidget: null - }; + // Over empty canvas Sticky keeps the last node, until it goes or another graph is on screen + if (info.hoveredNode) { + this.stickyNodeId = info.hoveredNode.id; + this.stickyGraph = app.canvas?.graph; + } else if (settings["🔍MagnifyGlass.InfoPanelPersist"] && this.stickyNodeId !== null) { + const stickyNode = this.stickyGraph === app.canvas?.graph ? this.uiManager.nodeSelector.getNodeById(this.stickyNodeId) : null; + if (stickyNode) { + info = { ...info, hoveredNode: this.informationGatherer.getDetailedNodeInfo(stickyNode) }; + } else { + this.stickyNodeId = null; } } - // END NODE SELECTION OVERRIDE - // PERSISTENCE LOGIC START - if (info.hoveredNode) { - // We have a valid node, update our cache - this.lastValidNodeInfo = info; - } else if (settings["🔍MagnifyGlass.InfoPanelPersist"] && this.lastValidNodeInfo) { - // No node hovered, but persistence is ON and we have a cache. - // Merge the CACHED node info into the CURRENT info (so we keep current cursor pos/zoom but show old node) - info = { - ...info, // Current timestamp, cursor, zoom - hoveredNode: this.lastValidNodeInfo.hoveredNode, - hoveredWidget: this.lastValidNodeInfo.hoveredWidget - }; - } - // PERSISTENCE LOGIC END - - this.stateManager.setCurrentInfo(info); + state.currentNodeId = info.hoveredNode?.id ?? null; // Update the UI this.uiManager.displayInfo(info); @@ -288,48 +286,6 @@ export class InfoPanel { this.positionManager.positionPanel(); this.positionManager.positionFloatingControls(this.uiManager.elements.controls); - - // Handle node hover state logic if needed for internal state - if (info.hoveredNode) { - this.stateManager.state.isHoveringNode = true; - this.stateManager.expandNodeSections(); - if (this.stateManager.state.lastNodeId !== info.hoveredNode.id) { - this.stateManager.state.lastNodeId = info.hoveredNode.id; - } - } else { - this.stateManager.state.isHoveringNode = false; - // Only collapse if we are NOT persisting - if (!settings["🔍MagnifyGlass.InfoPanelPersist"]) { - if (this.stateManager.state.lastNodeId !== null) { - this.stateManager.state.lastNodeId = null; - if (!this.stateManager.state.isPanelHovered) { - this.stateManager.scheduleAutoCollapse(); - } - } - } - } - } - - /** - * Handle node selection from dropdown. - * @param nodeId - ID of the selected node - */ - onNodeSelected(nodeId: number): void { - Logger.debug(`Node selected from dropdown: ${nodeId}`); - - // Set the selected node in state manager - this.stateManager.setSelectedNode(nodeId); - - // Force an immediate info update (bypasses isInfoHeld) - this.updateInfo(true); - } - - /** - * Clear the selected node, returning to hover-based detection. - */ - clearSelectedNode(): void { - this.stateManager.clearSelectedNode(); - this.updateInfo(); } } diff --git a/src/info-panel/InformationGatherer.ts b/src/info-panel/InformationGatherer.ts index 46558a2..86582e0 100644 --- a/src/info-panel/InformationGatherer.ts +++ b/src/info-panel/InformationGatherer.ts @@ -5,7 +5,7 @@ * Responsible for collecting information about nodes, widgets, and canvas state. */ -import type { ComfyApp, ComfyNode, ComfyWidget, GatheredInfo, NodeInfo, WidgetInfo, MediaInfo } from '../types/comfyui'; +import type { ComfyApp, ComfyGraph, ComfyNode, GatheredInfo, NodeInfo } from '../types/comfyui'; declare const app: ComfyApp; declare const LiteGraph: any; @@ -21,85 +21,41 @@ export class InformationGatherer { const magnifyGlass = window.comfyUIMagnifyGlass; if (!magnifyGlass) { return { - timestamp: Date.now(), - cursor: { screenX: 0, screenY: 0, canvasX: 0, canvasY: 0 }, + cursor: { canvasX: 0, canvasY: 0 }, zoom: 1, - nodeCount: 0, - hoveredNode: null, - hoveredWidget: null, - mediaElement: null + hoveredNode: null }; } const info: GatheredInfo = { - timestamp: Date.now(), cursor: { - screenX: magnifyGlass.lastKnownMousePosition.x, - screenY: magnifyGlass.lastKnownMousePosition.y, canvasX: magnifyGlass.state.x, canvasY: magnifyGlass.state.y }, zoom: magnifyGlass.state.canvasScale, - nodeCount: app.graph?._nodes?.length ?? 0, - hoveredNode: null, - hoveredWidget: null, - mediaElement: null + hoveredNode: null }; - if (magnifyGlass.isOverMedia && magnifyGlass.currentMediaElement) { - info.mediaElement = this.getMediaInfo(magnifyGlass.currentMediaElement); - } - const nodeUnderCursor = this.getNodeUnderCursor(); if (nodeUnderCursor) { - info.hoveredNode = this.getDetailedNodeInfo(nodeUnderCursor.node, nodeUnderCursor.localPos); - - const widget = this.getWidgetUnderCursor(nodeUnderCursor.node, nodeUnderCursor.localPos); - if (widget) { - info.hoveredWidget = this.getWidgetInfo(widget); - } + info.hoveredNode = this.getDetailedNodeInfo(nodeUnderCursor); } return info; } - getNodeUnderCursor(): { node: ComfyNode, localPos: { x: number, y: number } } | null { + getNodeUnderCursor(): ComfyNode | null { const magnifyGlass = window.comfyUIMagnifyGlass; - if (!app.graph || !app.canvas || !magnifyGlass) { + const graph = app.canvas?.graph; + if (!graph || !magnifyGlass) { return null; } + if (app.canvas.node_over) { + return app.canvas.node_over; + } + try { - // Try to get the node from ComfyUI's state - if (app.canvas.node_over) { - const node = app.canvas.node_over; - if (node && node.pos && node.size) { - const canvasRect = app.canvas.canvas.getBoundingClientRect(); - const mouseX = magnifyGlass.lastKnownMousePosition.x - canvasRect.left; - const mouseY = magnifyGlass.lastKnownMousePosition.y - canvasRect.top; - - let graphPos: [number, number]; - if (app.canvas.convertOffsetToCanvasPos) { - graphPos = app.canvas.convertOffsetToCanvasPos([mouseX, mouseY]); - } else { - const ds = app.canvas.ds || { scale: 1, offset: [0, 0] }; - graphPos = [ - (mouseX / ds.scale) - (ds.offset[0] / ds.scale), - (mouseY / ds.scale) - (ds.offset[1] / ds.scale) - ]; - } - - return { - node: node, - localPos: { - x: graphPos[0] - node.pos[0], - y: graphPos[1] - node.pos[1] - } - }; - } - } - - // Fallback to manual detection const canvasRect = app.canvas.canvas.getBoundingClientRect(); const mouseX = magnifyGlass.lastKnownMousePosition.x - canvasRect.left; const mouseY = magnifyGlass.lastKnownMousePosition.y - canvasRect.top; @@ -115,24 +71,7 @@ export class InformationGatherer { ]; } - for (let i = app.graph._nodes.length - 1; i >= 0; i--) { - const node = app.graph._nodes[i]; - if (!node.pos || !node.size || (node.flags && node.flags.collapsed)) continue; - - if (graphPos[0] >= node.pos[0] && - graphPos[0] <= node.pos[0] + node.size[0] && - graphPos[1] >= node.pos[1] && - graphPos[1] <= node.pos[1] + node.size[1]) { - - return { - node: node, - localPos: { - x: graphPos[0] - node.pos[0], - y: graphPos[1] - node.pos[1] - } - }; - } - } + return graph.getNodeOnPos(graphPos[0], graphPos[1]) ?? null; } catch (err) { console.warn("Error in node detection:", err); } @@ -140,9 +79,8 @@ export class InformationGatherer { return null; } - getDetailedNodeInfo(node: ComfyNode, localPos: { x: number, y: number }): NodeInfo { - // Try to get author and category from node constructor or registered node types - let author: string | undefined; + getDetailedNodeInfo(node: ComfyNode): NodeInfo { + // Category and module from the node constructor or the registered node types let category: string | undefined; let executionOrder: number | undefined; let pythonModule: string | undefined; @@ -152,19 +90,15 @@ export class InformationGatherer { const nodeData = (node as any).constructor?.nodeData; if (nodeData) { pythonModule = nodeData.python_module; - author = nodeData.author || pythonModule?.split('.')[0]; category = nodeData.category; } // Try to get from LiteGraph registered node types - if (!author && typeof LiteGraph !== 'undefined') { + if (!pythonModule && typeof LiteGraph !== 'undefined') { const nodeType = (LiteGraph as any).registered_node_types?.[node.type]; if (nodeType) { - author = nodeType.nodeData?.author || nodeType.author; category = nodeType.category || category; - if (!pythonModule && nodeType.nodeData?.python_module) { - pythonModule = nodeType.nodeData.python_module; - } + pythonModule = nodeType.nodeData?.python_module; } } @@ -181,184 +115,51 @@ export class InformationGatherer { mode: this.getNodeModeText(node.mode), position: { x: Math.round(node.pos[0]), - y: Math.round(node.pos[1]), - formatted: `(${Math.round(node.pos[0])}, ${Math.round(node.pos[1])})` + y: Math.round(node.pos[1]) }, size: { width: Math.round(node.size[0]), - height: Math.round(node.size[1]), - formatted: `${Math.round(node.size[0])}×${Math.round(node.size[1])}` - }, - localPosition: { - x: Math.round(localPos.x), - y: Math.round(localPos.y), - formatted: `(${Math.round(localPos.x)}, ${Math.round(localPos.y)})`, - percentage: { - x: ((localPos.x / node.size[0]) * 100).toFixed(1), - y: ((localPos.y / node.size[1]) * 100).toFixed(1) - } - }, - counts: { - widgets: node.widgets ? node.widgets.length : 0, - inputs: node.inputs ? node.inputs.length : 0, - outputs: node.outputs ? node.outputs.length : 0, - properties: node.properties ? Object.keys(node.properties).length : 0 + height: Math.round(node.size[1]) }, widgets: node.widgets || [], inputs: node.inputs || [], outputs: node.outputs || [], properties: node.properties || {}, - hoverRegion: this.detectNodeRegion(localPos, node), executionOrder: executionOrder, - author: author, category: category, - pythonModule: pythonModule + pythonModule: pythonModule, + graphPath: this.getGraphPath(node.graph), + group: node.graph?.getGroupOnPos(node.pos[0] + node.size[0] / 2, node.pos[1] + node.size[1] / 2)?.title, + isSubgraph: !!node.isSubgraphNode?.() }; } - getNodeInfo(node: ComfyNode): NodeInfo { - return { - id: node.id, - title: node.title || "Untitled", - type: node.type, - mode: node.mode, - size: node.size ? `${Math.round(node.size[0])}×${Math.round(node.size[1])}` : "Unknown", - position: node.pos ? `(${Math.round(node.pos[0])}, ${Math.round(node.pos[1])})` : "Unknown", - widgets: node.widgets || [], - inputs: node.inputs || [], - outputs: node.outputs || [], - properties: node.properties || {} - }; - } + /** + * Names of the subgraphs that lead from the root graph to this one, found through the subgraph nodes. + */ + getGraphPath(graph: ComfyGraph | undefined): string[] { + if (!graph || graph.rootGraph === graph) return []; - getWidgetUnderCursor(node: ComfyNode, localPos: { x: number, y: number }): ComfyWidget | null { - if (!node.widgets || !node.widgets.length) return null; - - const titleHeight = 30; - let currentY = titleHeight; - - for (const widget of node.widgets) { - const widgetHeight = 25; - - if (localPos.y >= currentY && localPos.y <= currentY + widgetHeight) { - return widget; + const find = (nodes: ComfyNode[], path: string[]): string[] | null => { + for (const node of nodes) { + if (!node.subgraph || !node.isSubgraphNode?.()) continue; + const subPath = [...path, node.subgraph.name || 'Subgraph']; + const found = node.subgraph === graph ? subPath : find(node.subgraph._nodes, subPath); + if (found) return found; } - - currentY += widgetHeight + 5; - } - - return null; - } - - getWidgetInfo(widget: ComfyWidget): WidgetInfo { - return { - name: widget.name, - type: widget.type, - value: this.formatValue(widget.value), - options: widget.options || null, - min: widget.min, - max: widget.max, - step: widget.step + return null; }; - } - - getMediaInfo(mediaElement: HTMLImageElement | HTMLVideoElement): MediaInfo { - const info: MediaInfo = { - type: mediaElement instanceof HTMLImageElement ? 'image' : 'video', - tagName: mediaElement.tagName, - src: mediaElement.src ? mediaElement.src.substring(mediaElement.src.lastIndexOf('/') + 1) : 'No source', - naturalWidth: 0, - naturalHeight: 0, - displayWidth: 0, - displayHeight: 0, - aspectRatio: '' - }; - - if (mediaElement instanceof HTMLImageElement) { - info.naturalWidth = mediaElement.naturalWidth; - info.naturalHeight = mediaElement.naturalHeight; - info.displayWidth = Math.round(mediaElement.width); - info.displayHeight = Math.round(mediaElement.height); - if (mediaElement.naturalWidth && mediaElement.naturalHeight) { - info.aspectRatio = (mediaElement.naturalWidth / mediaElement.naturalHeight).toFixed(2); - } - } else if (mediaElement instanceof HTMLVideoElement) { - info.naturalWidth = mediaElement.videoWidth; - info.naturalHeight = mediaElement.videoHeight; - info.displayWidth = Math.round(mediaElement.width); - info.displayHeight = Math.round(mediaElement.height); - info.duration = mediaElement.duration; - info.currentTime = mediaElement.currentTime; - if (mediaElement.videoWidth && mediaElement.videoHeight) { - info.aspectRatio = (mediaElement.videoWidth / mediaElement.videoHeight).toFixed(2); - } - } - - return info; + return find(graph.rootGraph._nodes, []) ?? []; } getNodeModeText(mode: number): string { const modes: { [key: number]: string } = { 0: 'Always', 1: 'On Event', - 2: 'Never', + 2: 'Mute', 3: 'On Trigger', - 4: 'On Request' + 4: 'Bypass' }; return modes[mode] || `Mode ${mode}`; } - - detectNodeRegion(localPos: { x: number, y: number }, node: ComfyNode): string { - const titleHeight = 30; - const regions: string[] = []; - - if (localPos.y <= titleHeight) { - regions.push("Title Bar"); - } - - if (localPos.x <= 10) { - regions.push("Left Edge"); - } else if (localPos.x >= node.size[0] - 10) { - regions.push("Right Edge"); - } - - if (localPos.y <= 10) { - regions.push("Top Edge"); - } else if (localPos.y >= node.size[1] - 10) { - regions.push("Bottom Edge"); - } - - if (regions.length === 0) { - if (localPos.y > titleHeight) { - regions.push("Content Area"); - } - } - - if (localPos.x <= 20 && localPos.y > titleHeight) { - regions.push("Input Area"); - } else if (localPos.x >= node.size[0] - 20 && localPos.y > titleHeight) { - regions.push("Output Area"); - } - - return regions.length > 0 ? regions.join(", ") : "Unknown"; - } - - formatValue(value: unknown): string { - if (value === null) return "null"; - if (value === undefined) return "undefined"; - if (typeof value === "string") { - return value; // Show full text without truncation - } - if (typeof value === "number") { - return Number.isInteger(value) ? value.toString() : value.toFixed(3); - } - if (typeof value === "boolean") return value.toString(); - if (Array.isArray(value)) { - return `Array(${value.length})`; - } - if (typeof value === "object") { - return "Object"; - } - return String(value); - } } diff --git a/src/info-panel/NodeDataExtractor.ts b/src/info-panel/NodeDataExtractor.ts index 7610cf0..c113bb9 100644 --- a/src/info-panel/NodeDataExtractor.ts +++ b/src/info-panel/NodeDataExtractor.ts @@ -7,18 +7,7 @@ import { formatWidgetValue } from './ValueFormatter'; import type { NodeInfo } from '../types/comfyui'; -import { WidgetSyncManager } from './widget-editors/WidgetSyncManager'; - -/** - * Widget constraints for editors - */ -export interface WidgetConstraintsInfo { - min?: number; - max?: number; - step?: number; - precision?: number; - options?: unknown[]; -} +import { WidgetSyncManager, type WidgetConstraints } from './widget-editors/WidgetSyncManager'; /** * Interface for extracted parameter items. @@ -32,206 +21,19 @@ export interface ParameterItem { widgetType?: string; // Widget type (number, combo, text, button, etc.) isEditable?: boolean; // Whether this can be edited (value-based) isActionable?: boolean; // Whether this is an action button (callback-based) + isLinked?: boolean; // Driven by a link, so shown read-only rawValue?: unknown; // Original unformatted value - constraints?: WidgetConstraintsInfo; // Widget constraints for validation + constraints?: WidgetConstraints; // Widget constraints for validation nodeId?: number; // Node ID for sync operations } /** - * Get checkpoint/model info from a node. - * @param nodeInfo - Node information object - * @returns Model filename or null - */ -export function getCheckpointInfo(nodeInfo: NodeInfo): string | null { - if (nodeInfo.type && ( - nodeInfo.type.includes('CheckpointLoader') || - nodeInfo.type.includes('LoadCheckpoint') || - nodeInfo.type.includes('ModelLoader') || - nodeInfo.type.includes('UNETLoader') || - nodeInfo.type.includes('VAELoader') || - nodeInfo.type.includes('LoraLoader') - )) { - if (nodeInfo.widgets && nodeInfo.widgets.length > 0) { - for (const widget of nodeInfo.widgets) { - if (widget.name && ( - widget.name.toLowerCase().includes('model') || - widget.name.toLowerCase().includes('checkpoint') || - widget.name.toLowerCase().includes('ckpt') || - widget.name.toLowerCase().includes('lora') || - widget.name.toLowerCase().includes('vae') || - widget.name.toLowerCase().includes('file') - )) { - const value = String(widget.value); - const filename = value.split(/[\/\\]/).pop(); - return filename || value; - } - } - } - } - return null; -} - -/** - * Image info result type. - */ -export interface ImageInfoResult { - width: string | number; - height: string | number; - src: string; -} - -/** - * Get image info from a node. - * @param nodeInfo - Node information object - * @returns Image info string, object, or null - */ -export function getImageInfo(nodeInfo: NodeInfo): string | ImageInfoResult | null { - if (nodeInfo.type && ( - nodeInfo.type.includes('SaveImage') || - nodeInfo.type.includes('PreviewImage') || - nodeInfo.type.includes('VisionOutput') || - nodeInfo.type.includes('ImageOutput') || - nodeInfo.type.includes('LoadImage') || - nodeInfo.type.includes('Display') - )) { - if (nodeInfo.widgets) { - for (const widget of nodeInfo.widgets) { - if (widget.name && ( - widget.name.toLowerCase().includes('image') || - widget.name.toLowerCase().includes('filename') || - widget.name.toLowerCase().includes('file') - )) { - return String(widget.value); - } - } - } - - if (nodeInfo.properties && (nodeInfo.properties as Record).img) { - const img = (nodeInfo.properties as Record).img as Record; - return { - width: (img.width as string | number) || 'unknown', - height: (img.height as string | number) || 'unknown', - src: img.src ? String(img.src).split(/[\/\\]/).pop() || 'Preview available' : 'Preview available' - }; - } - - if (nodeInfo.outputs) { - for (const output of nodeInfo.outputs) { - if (output.links && output.links.length > 0) { - return 'Image connected to ' + output.links.length + ' node(s)'; - } - } - } - - return 'Image node'; - } - return null; -} - -/** - * Get text box content from a node. - * @param nodeInfo - Node information object - * @returns Text content or null - */ -export function getTextBoxContent(nodeInfo: NodeInfo): string | null { - if (nodeInfo.widgets && nodeInfo.widgets.length > 0) { - if (nodeInfo.type && nodeInfo.type.includes('CLIPTextEncode')) { - for (const widget of nodeInfo.widgets) { - if (widget.name === 'text' && typeof widget.value === 'string') { - return widget.value; - } - } - } - - for (const widget of nodeInfo.widgets) { - if ((widget.name.toLowerCase().includes('prompt') || - widget.name.toLowerCase().includes('conditioning')) && - typeof widget.value === 'string' && widget.value.length > 0) { - return widget.value; - } - } - - for (const widget of nodeInfo.widgets) { - if ((widget.type === 'text' || widget.type === 'textarea' || - widget.type === 'string' || widget.name.toLowerCase().includes('text')) && - typeof widget.value === 'string' && widget.value.length > 0) { - return widget.value; - } - } - } - return null; -} - -/** - * List of important parameter names for complex nodes. - */ -const COMPLEX_NODE_PARAMS = [ - 'seed', 'steps', 'cfg', 'scale', 'sampler', 'scheduler', - 'positive', 'negative', 'width', 'height', 'denoise', 'strength', - 'noise', 'count', 'batch', 'size', 'phase', 'color', 'intensity', - 'control_after_generate', 'control', 'after', 'generate', - 'start_at_step', 'end_at_step', 'start', 'end', - 'return_with_leftover_noise', 'leftover', 'noise_return', - 'model', 'vae', 'clip', 'lora', 'checkpoint', - 'latent', 'image', 'mask', 'filename', 'directory', - 'prompt', 'conditioning', 'filename_prefix', - 'resolution', 'num_chunks', 'seconds', 'aspect_ratio', - 'style_type', 'background', 'n', 'human', 'raw', 'guidance', - 'skip_preprocessing', 'movement_amplitude', 'animation', - 'material_type', 'b1', 'b2', 's1', 's2', 'type', 'channel', - 'sigma', 'rho', 'alpha', 'base_shift', 'shift', 'stretch', 'terminal', - 'spacing', 'style', 'eta', 'norm_threshold', 'momentum', - 'hypernetwork_name', 'reuse_threshold', 'verbose', 'layers', - 'set_cond_area', 'audioui', 'camera_pose', 'fx', 'cx', 'fy', 'cy' -]; - -/** - * Parameters for save nodes. - */ -const SAVE_NODE_PARAMS = [ - 'filename_prefix', 'filename', 'directory', 'path', - 'format', 'quality', 'extension' -]; - -/** - * Generic widget names to skip. + * Generic names to skip unless an input slot shows they are a real widget (CLIPLoader has a `type` widget). */ const SKIP_WIDGET_NAMES = ['title', 'node', 'id', 'type', 'mode']; /** - * Check if a node type should show all widgets. - * @param nodeType - The node type string - * @returns True if all widgets should be shown - */ -function shouldShowAllWidgets(nodeType: string): boolean { - const type = nodeType.toLowerCase(); - const isSaveNode = type.includes('save') && - !type.includes('checkpoint') && - !type.includes('model') && - !type.includes('preview'); - - return ( - type.includes('ksampler') || - type.includes('sampler') || - type.includes('k_samplers') || - type.includes('checkpoint') || - type.includes('model') || - type.includes('lora') || - type.includes('controlnet') || - type.includes('advanced') || - type.includes('detailer') || - type.includes('inpaint') || - type.includes('upscale') || - type.includes('clip') || - type.includes('text') || - type.includes('encode') - ) && !isSaveNode; -} - -/** - * Get important node parameters based on node type. - * @param nodeInfo - Node information object - * @returns Array of parameter items with editing metadata + * The node's widgets as rows, with their editing metadata. */ export function getImportantNodeParameters(nodeInfo: NodeInfo): ParameterItem[] { const parameters: ParameterItem[] = []; @@ -240,68 +42,37 @@ export function getImportantNodeParameters(nodeInfo: NodeInfo): ParameterItem[] return parameters; } - const nodeType = nodeInfo.type || ''; - const typeLower = nodeType.toLowerCase(); - const isSaveNode = typeLower.includes('save') && - !typeLower.includes('checkpoint') && - !typeLower.includes('model') && - !typeLower.includes('preview'); - - // Track seen values to deduplicate widgets with identical values - const seenValues = new Set(); - - // Helper to normalize value for comparison (handles different types) - const normalizeValue = (value: unknown): string => { - if (value === null || value === undefined) return ''; - if (typeof value === 'string') return value.trim(); - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - return JSON.stringify(value); - }; - - // Helper to check if value is meaningful (not empty/default) - const isMeaningfulValue = (value: unknown): boolean => { - if (value === null || value === undefined) return false; - if (typeof value === 'string') return value.trim().length > 0; - return true; - }; + const inputs = nodeInfo.inputs || []; + const widgetSlot = (widget: any) => inputs.find((input: any) => input.widget?.name === widget.name); // Helper to create parameter item with full metadata const createParameterItem = (widget: any): ParameterItem => { const widgetType = WidgetSyncManager.getWidgetType(widget); const constraints = WidgetSyncManager.extractConstraints(widget); - const isEditable = WidgetSyncManager.isWidgetEditable(widget); + const isLinked = !!widget.computedDisabled || widgetSlot(widget)?.link != null; + const isEditable = !isLinked && WidgetSyncManager.isWidgetEditable(widget); // Button widgets are actionable (can be clicked to trigger callbacks) const isActionable = widgetType === 'button'; return { - label: widget.name, - value: formatWidgetValue(widget.value), + label: widget.label || widget.name, + value: formatWidgetValue(widget.value, constraints.precision), // Editing metadata widgetName: widget.name, widgetType: widgetType, isEditable: isEditable, isActionable: isActionable, + isLinked: isLinked, rawValue: widget.value, constraints: constraints, nodeId: nodeInfo.id }; }; - // Helper to add widget (no longer deduplicating - each widget is unique by name) - const addWidget = (widget: any): boolean => { - parameters.push(createParameterItem(widget)); - return true; - }; - - // Show ALL widgets for all nodes (with skip list only) - // This matches the popout viewer's behavior + // Every named widget, as the pop-out shows them for (const widget of nodeInfo.widgets) { - if (widget.name && widget.name !== '') { - const widgetName = widget.name.toLowerCase(); - // Use exact matching for skip list to avoid false positives - if (!SKIP_WIDGET_NAMES.includes(widgetName)) { - addWidget(widget); - } + if (widget.name && (!SKIP_WIDGET_NAMES.includes(widget.name.toLowerCase()) || widgetSlot(widget))) { + parameters.push(createParameterItem(widget)); } } diff --git a/src/info-panel/NodeSelector.ts b/src/info-panel/NodeSelector.ts index e442ec1..c24132e 100644 --- a/src/info-panel/NodeSelector.ts +++ b/src/info-panel/NodeSelector.ts @@ -10,18 +10,13 @@ import type { ComfyApp, ComfyNode } from '../types/comfyui'; declare const app: ComfyApp; /** - * Node entry for dropdown display. + * Node entry for the Go to node picker and Prev/Next. */ export interface NodeListEntry { id: number; title: string; type: string; -} - -/** - * Node entry with execution order. - */ -export interface NodeExecOrderEntry extends NodeListEntry { + /** Execution order, -1 when the node has none */ order: number; } @@ -34,71 +29,47 @@ export class NodeSelector { * Get all nodes from the graph. */ getAllNodes(): ComfyNode[] { - return app?.graph?._nodes ?? []; + return app?.canvas?.graph?._nodes ?? []; } - /** - * Get nodes sorted alphabetically by title. - */ - getNodesSortedByTitle(): NodeListEntry[] { - return this.getAllNodes() - .map(n => ({ - id: n.id, - title: n.title || 'Untitled', - type: n.type || 'Unknown' - })) - .sort((a, b) => a.title.localeCompare(b.title)); + private getEntries(): NodeListEntry[] { + return this.getAllNodes().map(n => ({ + id: n.id, + title: n.title || 'Untitled', + type: n.type || 'Unknown', + order: (n as any).order ?? -1 + })); } /** * Get nodes sorted by execution order. * Only includes nodes that have a valid execution order. */ - getNodesSortedByExecOrder(): NodeExecOrderEntry[] { - return this.getAllNodes() - .map(n => ({ - id: n.id, - title: n.title || 'Untitled', - type: n.type || 'Unknown', - order: (n as any).order ?? -1 - })) + getNodesSortedByExecOrder(): NodeListEntry[] { + return this.getEntries() .filter(n => n.order >= 0) .sort((a, b) => a.order - b.order); } - /** - * Get nodes sorted by ID number (ascending). - */ - getNodesSortedById(): NodeListEntry[] { - return this.getAllNodes() - .map(n => ({ - id: n.id, - title: n.title || 'Untitled', - type: n.type || 'Unknown' - })) - .sort((a, b) => a.id - b.id); - } - /** * Get a node by its ID. */ getNodeById(id: number): ComfyNode | null { - return app?.graph?.getNodeById(id) ?? null; + return app?.canvas?.graph?.getNodeById(id) ?? null; } /** - * Search nodes by title (case-insensitive partial match). + * Nodes whose title or type contains the query (any case), or whose id it names as `#id` or plainly. + * Sorted by execution order; nodes without one come last, by title. */ - searchByTitle(query: string): NodeListEntry[] { - const lowerQuery = query.toLowerCase(); - return this.getNodesSortedByTitle() - .filter(n => n.title.toLowerCase().includes(lowerQuery)); - } - - /** - * Get total node count. - */ - getNodeCount(): number { - return this.getAllNodes().length; + search(query: string): NodeListEntry[] { + const q = query.trim().toLowerCase(); + const idQuery = q.startsWith('#') ? q.slice(1) : null; + const rank = (n: NodeListEntry) => n.order < 0 ? Infinity : n.order; + return this.getEntries() + .filter(n => idQuery !== null + ? !idQuery || String(n.id) === idQuery + : n.title.toLowerCase().includes(q) || n.type.toLowerCase().includes(q) || String(n.id) === q) + .sort((a, b) => (rank(a) - rank(b)) || a.title.localeCompare(b.title)); } } diff --git a/src/info-panel/PositionManager.ts b/src/info-panel/PositionManager.ts index 06bb813..f077eb2 100644 --- a/src/info-panel/PositionManager.ts +++ b/src/info-panel/PositionManager.ts @@ -20,28 +20,24 @@ export class PositionManager { positionPanel(): void { if (!this.panelElement) return; - // If panel is pinned, keep it at its pinned position - if (this.stateManager.state.isPanelPinned) { - this.applyPinnedPosition(); + if (this.stateManager.state.isFloating) { + this.applyFloatPosition(); return; } - // Normal positioning logic this.calculateNormalPosition(); } - applyPinnedPosition(): void { - if (!this.panelElement) return; - - let { x, y } = this.stateManager.state.pinnedPosition; - - // If pinnedPosition is uninitialized (0,0), use calculateNormalPosition to get a valid position - if (x === 0 && y === 0) { + /** + * Keep a floating panel where it was put, 4px inside the window. It starts floating where it last followed the glass. + */ + applyFloatPosition(): void { + const state = this.stateManager.state; + if (!state.floatPosition) { this.calculateNormalPosition(); - // Save the calculated position as the new pinned position - if (this.panelElement) { - const rect = this.panelElement.getBoundingClientRect(); - this.stateManager.state.pinnedPosition = { x: rect.left, y: rect.top }; + // Not the element's rect, which is empty while the panel is hidden + if (this.cachedPanelPosition) { + state.floatPosition = { x: this.cachedPanelPosition.x, y: this.cachedPanelPosition.y }; } return; } @@ -50,8 +46,10 @@ export class PositionManager { const panelHeight = this.panelElement.offsetHeight || 400; const margin = 4; - const boundedX = Math.max(margin, Math.min(x, window.innerWidth - panelWidth - margin)); - const boundedY = Math.max(margin, Math.min(y, window.innerHeight - panelHeight - margin)); + const boundedX = Math.max(margin, Math.min(state.floatPosition.x, window.innerWidth - panelWidth - margin)); + const boundedY = Math.max(margin, Math.min(state.floatPosition.y, window.innerHeight - panelHeight - margin)); + // Stored bounded, so a drag past the edge turns back at once + state.floatPosition = { x: boundedX, y: boundedY }; this.panelElement.style.left = `${boundedX}px`; this.panelElement.style.top = `${boundedY}px`; @@ -191,41 +189,31 @@ export class PositionManager { // Enforce no transition on controls to prevent 'rubber-banding' controlsElement.style.transition = 'none'; - const controlsPosition = this.stateManager.state.settings["🔍MagnifyGlass.ControlsPosition"] || "right"; + // left/right: beside the reference; top-*/bottom-*: above or below it, held by the named edge. + // A side without room flips to the other, so the toolbar never covers the panel. + const [side, edge] = String(this.stateManager.state.settings["🔍MagnifyGlass.ControlsPosition"] || "left").split('-'); + const width = controlsElement.offsetWidth; + const height = controlsElement.offsetHeight; const margin = 4; let left: number; let top: number; - switch (controlsPosition) { - case "left": - left = referenceRect.left - controlsElement.offsetWidth - margin; - top = referenceRect.top; - break; - case "right": - left = referenceRect.right + margin; - top = referenceRect.top; - break; - case "top": - left = referenceRect.left; - top = referenceRect.top - controlsElement.offsetHeight - margin; - break; - case "bottom": - left = referenceRect.left; - top = referenceRect.bottom + margin; - break; - case "top-right": - left = referenceRect.right - controlsElement.offsetWidth; - top = referenceRect.top - controlsElement.offsetHeight - margin; - break; - default: - left = referenceRect.right + margin; - top = referenceRect.top; + if (side === 'top' || side === 'bottom') { + const above = referenceRect.top - height - margin; + const below = referenceRect.bottom + margin; + top = side === 'top' ? (above >= margin ? above : below) : (below + height <= window.innerHeight - margin ? below : above); + left = edge === 'right' ? referenceRect.right - width : referenceRect.left; + } else { + const before = referenceRect.left - width - margin; + const after = referenceRect.right + margin; + left = side === 'left' ? (before >= margin ? before : after) : (after + width <= window.innerWidth - margin ? after : before); + top = referenceRect.top; } // Keep controls within viewport - left = Math.max(10, Math.min(left, window.innerWidth - controlsElement.offsetWidth - 10)); - top = Math.max(10, Math.min(top, window.innerHeight - controlsElement.offsetHeight - 10)); + left = Math.max(margin, Math.min(left, window.innerWidth - width - margin)); + top = Math.max(margin, Math.min(top, window.innerHeight - height - margin)); controlsElement.style.left = `${left}px`; controlsElement.style.top = `${top}px`; diff --git a/src/info-panel/StateManager.ts b/src/info-panel/StateManager.ts index f79d45a..b235d3a 100644 --- a/src/info-panel/StateManager.ts +++ b/src/info-panel/StateManager.ts @@ -4,49 +4,37 @@ * Centralized state management for the information panel. */ -import type { GatheredInfo } from '../types/comfyui'; import { getSettingValue, clamp } from '../shared/utils'; import { DEFAULT_PANEL_SETTINGS } from '../shared/settings'; +import { PANEL_FONTS, STORAGE_KEYS } from '../shared/constants'; export interface InfoPanelStateData { // Panel visibility and positioning isPanelVisible: boolean; wasPanelVisibleBeforeHide: boolean; // Persist state across glass toggles isPanelMinimized: boolean; - isPanelPinned: boolean; - isPanelLocked: boolean; // New lock state - isAutoPinned: boolean; // Track if panel was auto-pinned by glass hide - pinnedPosition: { x: number; y: number }; - lastPinnedPosition: { x: number; y: number } | null; // Remember last pinned location + // Floating: stays where it was dragged instead of following the glass + isFloating: boolean; + // Where it floats; null until it first floats + floatPosition: { x: number; y: number } | null; // Glass visibility isGlassPreviewVisible: boolean; - // Info freezing - isInfoHeld: boolean; - - // Section expansion - expandedSections: Set; - - // Interaction states isPanelHovered: boolean; - isHoveringNode: boolean; - // Auto-collapse - autoExpandTimer: ReturnType | null; - lastNodeId: string | number | null; + // The node the panel shows + currentNodeId: number | null; - // Current data - currentInfo: GatheredInfo | null; - - // Node selection override (user-selected node from dropdown) - selectedNodeId: number | null; + // Node the panel is locked to: shown instead of the hovered node until unlocked + lockedNodeId: number | null; + // The graph that id belongs to; another graph on screen releases the lock + lockedGraph: unknown; + // The shown node is the canvas selection (Follow selection) + showsSelection: boolean; // Settings cache - using index signature for dynamic ComfyUI settings settings: { [key: string]: string | number | boolean }; - - // Auto-detected theme - currentTheme: string; } /** @@ -55,11 +43,6 @@ export interface InfoPanelStateData { */ export class StateManager { state: InfoPanelStateData; - private themeObserver!: MutationObserver; - private themeInterval!: ReturnType; - private themeTimeout!: ReturnType; - private colorSchemeQuery!: MediaQueryList; - private readonly onColorSchemeChange = () => this.detectCurrentTheme(); constructor() { this.state = { @@ -67,243 +50,49 @@ export class StateManager { isPanelVisible: false, wasPanelVisibleBeforeHide: false, isPanelMinimized: false, - isPanelPinned: false, - isPanelLocked: false, // New lock state - isAutoPinned: false, // Track if panel was auto-pinned by glass hide - pinnedPosition: { x: 0, y: 0 }, - lastPinnedPosition: null, // Remember last pinned location + isFloating: false, + floatPosition: null, // Glass visibility isGlassPreviewVisible: true, - isInfoHeld: false, - // Section expansion - expandedSections: new Set(['node']), - - // Interaction states isPanelHovered: false, - isHoveringNode: false, + currentNodeId: null, - // Auto-collapse - autoExpandTimer: null, - lastNodeId: null, - - // Current data - currentInfo: null, - - // Node selection override - selectedNodeId: null, + lockedNodeId: null, + lockedGraph: null, + showsSelection: false, // Settings cache - settings: {}, - - // Auto-detected theme - currentTheme: 'dark' // Will be auto-detected + settings: {} }; - this.initThemeDetection(); this.loadSettings(); + this.loadLayout(); } - initThemeDetection(): void { - // Detect ComfyUI's current theme - this.detectCurrentTheme(); - - // Force an immediate check in case we missed the initial state - this.themeTimeout = setTimeout(() => { - if (this.detectCurrentTheme()) { - this.notifyThemeChange(); - } - }, 1000); - - // Set up mutation observer to watch for theme changes - this.setupThemeObserver(); - - // Listen for system theme changes as fallback - this.colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); - this.colorSchemeQuery.addEventListener('change', this.onColorSchemeChange); - - // Add click listener to theme toggle buttons for immediate detection - this.setupThemeButtonListeners(); - } - - detectCurrentTheme(): boolean { - let detectedTheme = 'dark'; // Default fallback - - // Method 1: Try to read from ComfyUI's color palette setting + /** + * Float mode, float position and minimized state are kept per browser. + */ + private loadLayout(): void { try { - // ComfyUI stores theme in 'Comfy.ColorPalette' setting - const colorPalette = getSettingValue('Comfy.ColorPalette', ''); - - if (colorPalette) { - // Extract theme name from various possible formats - const paletteStr = String(colorPalette).toLowerCase(); - - if (paletteStr.includes('obsidian') && paletteStr.includes('dark')) { - detectedTheme = 'obsidian-dark'; - } else if (paletteStr.includes('obsidian')) { - detectedTheme = 'obsidian'; - } else if (paletteStr.includes('milk') || paletteStr.includes('white')) { - detectedTheme = 'milk-white'; - } else if (paletteStr.includes('solarized')) { - detectedTheme = 'solarized'; - } else if (paletteStr.includes('arc')) { - detectedTheme = 'arc'; - } else if (paletteStr.includes('nord')) { - detectedTheme = 'nord'; - } else if (paletteStr.includes('github')) { - detectedTheme = 'github'; - } else if (paletteStr.includes('light')) { - detectedTheme = 'light'; - } else if (paletteStr.includes('dark')) { - detectedTheme = 'dark'; - } + const layout = JSON.parse(localStorage.getItem(STORAGE_KEYS.INSPECTOR_LAYOUT) || '{}'); + this.state.isFloating = layout.floating === true; + this.state.isPanelMinimized = layout.minimized === true; + if (Number.isFinite(layout.x) && Number.isFinite(layout.y)) { + this.state.floatPosition = { x: layout.x, y: layout.y }; } } catch (e) { - // Fallback to brightness-based detection - console.warn('ComfyUI Info Panel: Could not read theme from settings, using fallback detection'); + // Storage blocked or the saved layout unreadable: start from the defaults } - - // Method 2: Fallback - check body/html for theme classes or data attributes - if (detectedTheme === 'dark') { - const body = document.body; - const html = document.documentElement; - - // Check for data-theme attribute - const dataTheme = body.getAttribute('data-theme') || html.getAttribute('data-theme'); - if (dataTheme) { - detectedTheme = dataTheme.toLowerCase(); - } - - // Check for theme classes - const classes = (body.className + ' ' + html.className).toLowerCase(); - if (classes.includes('theme-obsidian-dark') || classes.includes('obsidian-dark')) { - detectedTheme = 'obsidian-dark'; - } else if (classes.includes('theme-obsidian') || classes.includes('obsidian')) { - detectedTheme = 'obsidian'; - } else if (classes.includes('theme-milk-white') || classes.includes('milk-white') || classes.includes('milk_white')) { - detectedTheme = 'milk-white'; - } else if (classes.includes('theme-solarized') || classes.includes('solarized')) { - detectedTheme = 'solarized'; - } else if (classes.includes('theme-arc') || classes.includes('arc-theme')) { - detectedTheme = 'arc'; - } else if (classes.includes('theme-nord') || classes.includes('nord-theme')) { - detectedTheme = 'nord'; - } else if (classes.includes('theme-github') || classes.includes('github-theme')) { - detectedTheme = 'github'; - } else if (classes.includes('theme-light') || classes.includes('light-theme')) { - detectedTheme = 'light'; - } else if (classes.includes('theme-dracula') || classes.includes('dracula-theme')) { - detectedTheme = 'dracula'; - } else if (classes.includes('theme-gruvbox') || classes.includes('gruvbox-theme')) { - detectedTheme = 'gruvbox'; - } else if (classes.includes('theme-monokai') || classes.includes('monokai-theme')) { - detectedTheme = 'monokai'; - } - } - - // Method 3: Last resort - brightness detection for light vs dark - if (detectedTheme === 'dark') { - const bodyStyles = window.getComputedStyle(document.body); - const bgColor = bodyStyles.backgroundColor; - if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') { - const rgbMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - if (rgbMatch) { - const [, r, g, b] = rgbMatch.map(Number); - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - if (brightness > 180) { - detectedTheme = 'light'; - } - } - } - } - - // Update theme if it changed - if (this.state.currentTheme !== detectedTheme) { - this.state.currentTheme = detectedTheme; - return true; // Theme changed - } - - return false; // No change } - setupThemeObserver(): void { - // Watch for changes to body/html classes and attributes - const observer = this.themeObserver = new MutationObserver((mutations) => { - let shouldCheck = false; - mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && - (mutation.attributeName === 'class' || - mutation.attributeName === 'data-theme' || - mutation.attributeName === 'style')) { - shouldCheck = true; - } - }); - - if (shouldCheck && this.detectCurrentTheme()) { - // Theme changed, update UI immediately - this.notifyThemeChange(); - } - }); - - // Observe changes to body, html, and vue app - observer.observe(document.body, { attributes: true, attributeOldValue: true }); - observer.observe(document.documentElement, { attributes: true, attributeOldValue: true }); - - const vueApp = document.querySelector('#vue-app'); - if (vueApp) { - observer.observe(vueApp, { attributes: true, attributeOldValue: true }); - } - - // Also watch for changes to any element with theme-related classes - const themeElements = document.querySelectorAll('[class*="theme"], [class*="dark"], [class*="light"], [data-theme]'); - themeElements.forEach(el => { - observer.observe(el, { attributes: true, attributeOldValue: true }); - }); - - // Periodic fallback check every 2 seconds - this.themeInterval = setInterval(() => { - if (this.detectCurrentTheme()) { - this.notifyThemeChange(); - } - }, 2000); - } - - setupThemeButtonListeners(): void { - document.addEventListener('click', this.onDocumentClick); - } - - // Clicks on theme toggle buttons trigger an immediate re-detection - private readonly onDocumentClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - const isThemeButton = target.textContent?.toLowerCase().includes('theme') || - target.textContent?.toLowerCase().includes('light') || - target.textContent?.toLowerCase().includes('dark') || - target.title?.toLowerCase().includes('theme') || - (typeof target.className === 'string' && target.className.toLowerCase().includes('theme')); - - if (isThemeButton) { - setTimeout(() => { - if (this.detectCurrentTheme()) { - this.notifyThemeChange(); - } - }, 100); - } - }; - - notifyThemeChange(): void { - // Update settings to reflect new theme - this.state.settings["🔍MagnifyGlass.InfoPanelTheme"] = this.state.currentTheme; - - // Notify UI components of theme change immediately - if (window.infoPanelManager && window.infoPanelManager.uiManager) { - window.infoPanelManager.uiManager.updateTheme(this.state.currentTheme); - } - - // Notify Pop-Out Manager (if it exists on the main glass instance) - const magnifyGlass = (window as any).comfyUIMagnifyGlass; - if (magnifyGlass && magnifyGlass.popOutManager) { - magnifyGlass.popOutManager.updateTheme(this.state.currentTheme); + saveLayout(): void { + const { isFloating, floatPosition, isPanelMinimized } = this.state; + try { + localStorage.setItem(STORAGE_KEYS.INSPECTOR_LAYOUT, JSON.stringify({ floating: isFloating, minimized: isPanelMinimized, ...floatPosition })); + } catch (e) { + // Storage blocked: the layout lasts until reload } } @@ -311,10 +100,7 @@ export class StateManager { const defaults = DEFAULT_PANEL_SETTINGS; Object.keys(defaults).forEach(key => { - // Skip the theme setting since we auto-detect it - if (key !== "🔍MagnifyGlass.InfoPanelTheme") { - this.state.settings[key] = getSettingValue(key, (defaults as any)[key]); - } + this.state.settings[key] = getSettingValue(key, (defaults as any)[key]); }); // Validate numeric settings @@ -354,16 +140,9 @@ export class StateManager { this.state.settings["🔍MagnifyGlass.ControlsPosition"], validControlPositions, defaults["🔍MagnifyGlass.ControlsPosition"] as string ); - const validFonts = [ - 'System Default', 'Inter', 'Roboto', 'JetBrains Mono', 'Fira Code', - 'IBM Plex Sans', 'Space Grotesk', 'Lexend', 'Outfit', 'monospace', 'system-ui' - ]; this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"] = this.validateStringOption( - this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"], validFonts, defaults["🔍MagnifyGlass.InfoPanelFontFamily"] as string + this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"], PANEL_FONTS, defaults["🔍MagnifyGlass.InfoPanelFontFamily"] as string ); - - // Set theme to auto-detected value - this.state.settings["🔍MagnifyGlass.InfoPanelTheme"] = this.state.currentTheme; } /** @@ -390,140 +169,13 @@ export class StateManager { /** * Validate string against an allowlist. */ - private validateStringOption(value: unknown, options: string[], fallback: string): string { + private validateStringOption(value: unknown, options: readonly string[], fallback: string): string { if (typeof value !== 'string') return fallback; return options.includes(value) ? value : fallback; } - togglePanelVisibility(): boolean { - this.state.isPanelVisible = !this.state.isPanelVisible; - return this.state.isPanelVisible; - } - - toggleGlassPreview(): boolean { - this.state.isGlassPreviewVisible = !this.state.isGlassPreviewVisible; - return this.state.isGlassPreviewVisible; - } - - togglePinning(): boolean { - const wasPinned = this.state.isPanelPinned; - this.state.isPanelPinned = !this.state.isPanelPinned; - - // When unpinning, also unlock and clear auto-pin flag - if (!this.state.isPanelPinned) { - this.state.isPanelLocked = false; - this.state.isAutoPinned = false; // Clear auto-pin flag when manually unpinning - } else { - // When manually pinning, also clear auto-pin flag (this is a user-initiated pin) - this.state.isAutoPinned = false; - } - - if (this.state.isPanelPinned && this.state.lastPinnedPosition) { - // Restore to last pinned position - this.state.pinnedPosition = { ...this.state.lastPinnedPosition }; - } else if (!this.state.isPanelPinned && wasPinned) { - // Save current position when unpinning - this.state.lastPinnedPosition = { ...this.state.pinnedPosition }; - } - - return this.state.isPanelPinned; - } - - toggleLocking(): boolean { - // Only allow locking when pinned - if (!this.state.isPanelPinned) { - return false; - } - - this.state.isPanelLocked = !this.state.isPanelLocked; - return this.state.isPanelLocked; - } - toggleMinimized(): boolean { this.state.isPanelMinimized = !this.state.isPanelMinimized; return this.state.isPanelMinimized; } - - toggleHold(): boolean { - this.state.isInfoHeld = !this.state.isInfoHeld; - return this.state.isInfoHeld; - } - - toggleSection(sectionId: string): boolean { - if (sectionId === 'node') return false; // Node section always expanded - - if (this.state.expandedSections.has(sectionId)) { - this.state.expandedSections.delete(sectionId); - } else { - this.state.expandedSections.add(sectionId); - } - return true; - } - - setPinnedPosition(x: number, y: number): void { - this.state.pinnedPosition = { x, y }; - // Also save as last pinned position for memory - this.state.lastPinnedPosition = { x, y }; - } - - setCurrentInfo(info: GatheredInfo): void { - this.state.currentInfo = info; - } - - /** - * Set the selected node ID for manual override. - * When set, the inspector will display this node instead of the hovered node. - */ - setSelectedNode(id: number | null): void { - this.state.selectedNodeId = id; - } - - /** - * Clear the selected node, returning to hover-based detection. - */ - clearSelectedNode(): void { - this.state.selectedNodeId = null; - } - - scheduleAutoCollapse(): void { - this.clearAutoExpandTimer(); - if (this.state.settings["🔍MagnifyGlass.InfoPanelAnimations"]) { - this.state.autoExpandTimer = setTimeout(() => { - if (!this.state.isPanelHovered) { - this.collapseNodeSections(); - } - }, 1500); - } - } - - clearAutoExpandTimer(): void { - if (this.state.autoExpandTimer) { - clearTimeout(this.state.autoExpandTimer); - this.state.autoExpandTimer = null; - } - } - - expandNodeSections(): void { - this.state.expandedSections.add('hoveredNode'); - this.state.expandedSections.add('node'); - this.state.expandedSections.add('cursor'); - this.state.expandedSections.add('canvas'); - this.state.expandedSections.add('magnifier'); - this.clearAutoExpandTimer(); - } - - collapseNodeSections(): void { - this.state.expandedSections.delete('hoveredNode'); - this.state.expandedSections.delete('node'); - this.state.expandedSections.delete('widget'); - } - - cleanup(): void { - this.clearAutoExpandTimer(); - clearTimeout(this.themeTimeout); - clearInterval(this.themeInterval); - this.themeObserver.disconnect(); - this.colorSchemeQuery.removeEventListener('change', this.onColorSchemeChange); - document.removeEventListener('click', this.onDocumentClick); - } } diff --git a/src/info-panel/UIManager.ts b/src/info-panel/UIManager.ts index d125729..778822a 100644 --- a/src/info-panel/UIManager.ts +++ b/src/info-panel/UIManager.ts @@ -7,19 +7,13 @@ import { StateManager } from './StateManager'; import { Icons } from '../shared/icons'; -import { Logger } from '../shared/logger'; import { INFO_PANEL_ID } from '../shared/constants'; -import { escapeHtml } from '../shared/utils'; -import { formatValue, getValueClass, getValueAttributes, formatWidgetValue } from './ValueFormatter'; -import { - getCheckpointInfo, - getImageInfo, - getTextBoxContent, - getImportantNodeParameters, - type ImageInfoResult, - type ParameterItem -} from './NodeDataExtractor'; -import { NodeSelector, type NodeListEntry, type NodeExecOrderEntry } from './NodeSelector'; +import { DEFAULT_PANEL_SETTINGS } from '../shared/settings'; +import { escapeHtml, isUserTyping, setSettingValue } from '../shared/utils'; +import { formatWidgetValue, isLongText } from './ValueFormatter'; +import { getImportantNodeParameters } from './NodeDataExtractor'; +import { NodeSelector, type NodeListEntry } from './NodeSelector'; +import type { ComfyNode, NodeInfo } from '../types/comfyui'; import { WidgetEditorFactory, WidgetSyncManager, @@ -46,13 +40,20 @@ export class UIManager { nodeSelector: NodeSelector; currentDropdown: HTMLDivElement | null = null; private currentDropdownCleanup: (() => void) | null = null; - onNodeSelected: ((nodeId: number) => void) | null = null; + // Updates the panel at once, for changes made without moving the mouse over the canvas + onRefresh: (() => void) | null = null; + // Nodes visited by jumps, for Back and Forward. Ids only mean something in the graph they came from + private history: { graph: unknown; ids: number[]; index: number } = { graph: null, ids: [], index: -1 }; // Track active widget editors for cleanup private activeEditors: Map = new Map(); // Track active inline controls for cleanup private activeInlineControls: Map = new Map(); // Track active drag controllers for cleanup private activeDragControllers: Map = new Map(); + // What the content was last rendered from; emptied when hide() tears the controls down + private renderKey: unknown[] = []; + // Long values the user expanded, by node id and label, so a re-render keeps them open + private expandedValues = new Set(); // Hotkey handler for Focus Node private hotkeyHandler: ((e: KeyboardEvent) => void) | null = null; @@ -78,136 +79,77 @@ export class UIManager { // Main panel container this.elements.panel = document.createElement("div"); this.elements.panel.id = INFO_PANEL_ID; - this.elements.panel.className = `magnify-info-panel theme-${this.stateManager.state.currentTheme}`; + this.elements.panel.className = 'magnify-info-panel'; + this.elements.panel.style.display = 'none'; - // Header + this.elements.panel.setAttribute('role', 'region'); + this.elements.panel.setAttribute('aria-label', 'Node inspector'); + + // Header: the node's identity, its lock and minimize this.elements.header = document.createElement("div"); this.elements.header.className = "panel-header"; this.elements.header.innerHTML = ` -
-
${Icons.magnifyGlass}
-
Inspector
-
Real-time analysis
-
-
- +
+ + +
+ + + `; + const nav = document.createElement("div"); + nav.className = "panel-nav"; + nav.setAttribute('role', 'toolbar'); + nav.setAttribute('aria-label', 'Node navigation'); + nav.innerHTML = ` + + + + + + + `; + + // Subgraph path and group of the node; hidden when it has neither + const breadcrumb = document.createElement("div"); + breadcrumb.className = "panel-breadcrumb"; + breadcrumb.hidden = true; + // Content container this.elements.content = document.createElement("div"); this.elements.content.className = "panel-content"; - this.elements.panel.appendChild(this.elements.header); - this.elements.panel.appendChild(this.elements.content); + // Drag it to set the Width and Max Height settings + const resize = document.createElement("div"); + resize.className = "panel-resize"; + resize.title = "Resize"; + + this.elements.panel.append(this.elements.header, nav, breadcrumb, this.elements.content, resize); + this.renderHeader(null); this.applyStyles(); - // Apply initial minimized state if set - if (this.stateManager.state.isPanelMinimized) { - this.elements.panel.classList.add('panel-minimized'); - this.updateMinimizedState(); // Correctly set aria-expanded state - } + this.updateMinimizedState(); document.body.appendChild(this.elements.panel); - // Hover-expand: Add/remove 'is-expanded' class, reposition, and resize font if needed - let originalTop: number | null = null; - let originalFontSize: number | null = null; - const MIN_FONT_SIZE = 10; // Minimum font size in pixels - const MARGIN = 20; // Minimum margin from viewport edges - - this.elements.panel.addEventListener('mouseenter', () => { - // Sticky Mode (Persist): disable this behavior to prevent bouncing - if (this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]) { - return; - } - - if (!this.stateManager.state.isPanelMinimized && this.elements.panel) { - // Save original position - originalTop = this.elements.panel.offsetTop; - // Save original font size just in case, though we won't change it - originalFontSize = parseFloat(getComputedStyle(this.elements.panel).fontSize); - - // Add expanded class - this.elements.panel.classList.add('is-expanded'); - - // Simple repositioning logic only (no font scaling) - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (!this.elements.panel) return; - - const viewportHeight = window.innerHeight; - const rect = this.elements.panel.getBoundingClientRect(); - - // Position the panel to fit (move up if needed) - if (rect.bottom > viewportHeight - MARGIN) { - const newTop = Math.max(MARGIN, originalTop! - (rect.bottom - viewportHeight + MARGIN)); - this.elements.panel.style.top = `${newTop}px`; - } - }); - }); - } - }); - - this.elements.panel.addEventListener('mouseleave', () => { - // Sticky Mode (Persist): disable this behavior to prevent bouncing - if (this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]) { - return; - } - - if (this.elements.panel) { - this.elements.panel.classList.remove('is-expanded'); - - // Restore original position - if (originalTop !== null) { - this.elements.panel.style.top = `${originalTop}px`; - originalTop = null; - } - - // Restore original font size (if it was somehow changed) - if (originalFontSize !== null) { - this.elements.panel.style.fontSize = `${originalFontSize}px`; - originalFontSize = null; - } - - // Clear any inline styles that might have been set - this.elements.panel.style.transform = ''; - this.elements.panel.style.transformOrigin = ''; - } - }); - - // Add click event delegation for panel elements this.elements.panel.addEventListener('click', (e: MouseEvent) => { const target = e.target as HTMLElement; - - // Handle minimize button - const minimizeBtn = target.closest('[data-action="minimize"]'); - if (minimizeBtn) { - this.stateManager.state.isPanelMinimized = !this.stateManager.state.isPanelMinimized; - this.updateMinimizedState(); - return; + const button = target.closest('button[data-action]') as HTMLButtonElement | null; + if (button && !button.disabled) { + this.handlePanelAction(button); } + }); - // Handle section header clicks for expand/collapse - const sectionHeader = target.closest('.section-header') as HTMLElement; - if (sectionHeader) { - const sectionId = sectionHeader.getAttribute('data-section'); - // Skip node section (always expanded) - if (sectionId && sectionId !== 'node') { - const isExpanded = this.stateManager.state.expandedSections.has(sectionId); - if (isExpanded) { - this.stateManager.state.expandedSections.delete(sectionId); - } else { - this.stateManager.state.expandedSections.add(sectionId); - } + // Focus left behind by a click (a button, a combo trigger) would keep ←/→ on the panel after the pointer leaves + this.elements.panel.addEventListener('mouseleave', () => { + const focused = document.activeElement as HTMLElement | null; + if (focused && this.elements.panel!.contains(focused) && !isUserTyping()) focused.blur(); + }); - // Update visual state - sectionHeader.classList.toggle('expanded', !isExpanded); - const sectionContent = sectionHeader.nextElementSibling as HTMLElement; - if (sectionContent && sectionContent.classList.contains('section-content')) { - sectionContent.classList.toggle('expanded', !isExpanded); - } - } - } + this.elements.header.addEventListener('dblclick', (e: MouseEvent) => { + if ((e.target as HTMLElement).closest('button')) return; + this.toggleMinimized(); }); // Create floating controls after panel is in DOM @@ -218,43 +160,126 @@ export class UIManager { } } + private handlePanelAction(button: HTMLButtonElement): void { + const state = this.stateManager.state; + switch (button.dataset.action) { + case 'minimize': + this.toggleMinimized(); + break; + case 'lock-node': + this.lockTo(state.lockedNodeId === null ? state.currentNodeId : null); + this.onRefresh?.(); + break; + case 'back': + this.goThroughHistory(-1); + break; + case 'forward': + this.goThroughHistory(1); + break; + case 'prev-node': + this.navigateExecOrder(-1); + break; + case 'next-node': + this.navigateExecOrder(1); + break; + case 'focus-node': + this.focusCurrentNode(); + break; + case 'go-to-node': + if (this.currentDropdown) { + this.hideDropdown(); + } else { + this.showNodePicker(button); + } + break; + case 'copy': + this.copyValue(button); + break; + case 'expand-value': { + const row = button.closest('.info-row') as HTMLElement; + const key = row.dataset.expandKey!; + const expanded = !this.expandedValues.has(key); + if (expanded) { + this.expandedValues.add(key); + } else { + this.expandedValues.delete(key); + } + row.classList.toggle('is-expanded', expanded); + button.textContent = expanded ? 'Show less' : 'Show more'; + button.setAttribute('aria-expanded', String(expanded)); + break; + } + case 'enter-subgraph': { + const node = state.currentNodeId === null ? null : this.nodeSelector.getNodeById(state.currentNodeId); + if (node?.subgraph) { + this.lockTo(null); + app.canvas.openSubgraph(node.subgraph, node); + } + break; + } + } + } + + private toggleMinimized(): void { + this.stateManager.toggleMinimized(); + this.stateManager.saveLayout(); + this.updateMinimizedState(); + } + /** - * Create floating control buttons. + * Lock the panel to a node of the graph on screen, or release it with null. + */ + private lockTo(nodeId: number | null): void { + this.stateManager.state.lockedNodeId = nodeId; + this.stateManager.state.lockedGraph = app.canvas?.graph; + } + + /** + * Switch between following the glass and floating. A hidden glass cannot be followed. + */ + toggleFloating(): void { + const state = this.stateManager.state; + if (state.isFloating && !state.isGlassPreviewVisible) return; + state.isFloating = !state.isFloating; + this.stateManager.saveLayout(); + this.updateControlStates(); + this.onRefresh?.(); + } + + /** + * Sticky from the toolbar, the eye button and the S key. The settings dialog and the sidebar + * save the setting themselves, and its onChange updates the toolbar. + */ + setPersist(persist: boolean): void { + this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"] = persist; + setSettingValue("🔍MagnifyGlass.InfoPanelPersist", persist); + this.updateControlStates(); + this.onRefresh?.(); + } + + /** + * Create the toolbar: panel, glass and pop-out groups with a fixed set of buttons. */ createFloatingControls(): void { this.elements.controls = document.createElement("div"); - this.elements.controls.className = `floating-controls vertical-layout theme-${this.stateManager.state.currentTheme}`; // Default to vertical + theme + this.elements.controls.className = 'floating-controls vertical-layout'; + this.elements.controls.setAttribute('role', 'toolbar'); + this.elements.controls.setAttribute('aria-label', 'Inspector and glass'); - /** - * BUTTON MAPPING DOCUMENTATION: - * ============================= - * Button 1 (Unlock Icon): data-action="pin" → controls isPanelPinned - * - Unpinned (default): Panel follows magnify glass - * - Pinned: Panel stays at fixed position (unlocked from following glass) - * - * Button 2 (Pin Icon): data-action="lock" → controls isPanelLocked - * - Unlocked (default): Panel can be dragged - * - Locked: Panel cannot be dragged (pinned in place) - * - * NOTE: The data-action names are counterintuitive but kept for backwards compatibility. - * The ICONS correctly represent the functionality: - * - Unlock icon = unlock from following glass - * - Pin icon = pin position (prevent drag) - */ this.elements.controls.innerHTML = ` - - - - - - - - - - + + + + + + + + + + + `; - // Insert before the panel in the document body, not as a child document.body.appendChild(this.elements.controls); // Hide and position off-screen initially - will be shown when magnify glass activates and positions them @@ -263,324 +288,139 @@ export class UIManager { this.elements.controls.style.left = '-9999px'; this.elements.controls.style.top = '-9999px'; - // Add click event delegation for control buttons this.elements.controls.addEventListener('click', (e: MouseEvent) => { - const target = e.target as HTMLElement; - const button = target.closest('button[data-action]') as HTMLButtonElement; - if (!button) return; - - const action = button.getAttribute('data-action'); - Logger.debug(`Control button clicked: ${action}`); - - switch (action) { - case 'pin': - this.stateManager.togglePinning(); - this.updatePinnedState(); - this.updateControlStates(); - break; - case 'lock': - this.stateManager.state.isPanelLocked = !this.stateManager.state.isPanelLocked; - this.updateControlStates(); - if (this.elements.panel) { - this.elements.panel.classList.toggle('panel-locked', this.stateManager.state.isPanelLocked); - } - break; - case 'persist': - this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"] = !this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]; - this.updateControlStates(); - this.applyStyles(); // Update persist-active class - // Sync sidebar setting if needed (optional, sidebar updates on reopen usually) - break; - case 'toggle-panel': - if (this.stateManager.state.isPanelVisible) { - // User requested to hide the panel - this.hide(); - - // User Request: Disable sticky info when hiding via button - if (this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]) { - this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"] = false; - this.applyStyles(); // Update visual styles if necessary - } - } else { - this.show(); - } - this.updateControlStates(); - break; - case 'toggle-glass': - this.stateManager.state.isGlassPreviewVisible = !this.stateManager.state.isGlassPreviewVisible; - - // Link Glass Visibility to Panel Pinning - if (!this.stateManager.state.isGlassPreviewVisible) { - // Glass Hidden - console.log(`[MagnifyGlass Debug] Glass Hidden. pinned=${this.stateManager.state.isPanelPinned}, autoPinned=${this.stateManager.state.isAutoPinned}`); - // Only auto-pin if not already pinned by the user - if (!this.stateManager.state.isPanelPinned) { - if (this.elements.panel) { - const rect = this.elements.panel.getBoundingClientRect(); - this.stateManager.state.pinnedPosition = { x: rect.left, y: rect.top }; - } - this.stateManager.state.isPanelPinned = true; - this.stateManager.state.isAutoPinned = true; // Track that this was auto-pinned - console.log(`[MagnifyGlass Debug] Auto-Pinned panel.`); - } - // Do NOT reset isPanelLocked here - preserve user's lock state - } else { - // Glass Shown - console.log(`[MagnifyGlass Debug] Glass Shown. pinned=${this.stateManager.state.isPanelPinned}, autoPinned=${this.stateManager.state.isAutoPinned}`); - // Only unpin if it was auto-pinned (not user-pinned) - if (this.stateManager.state.isAutoPinned) { - this.stateManager.state.isPanelPinned = false; - this.stateManager.state.isAutoPinned = false; - console.log(`[MagnifyGlass Debug] Auto-Unpinned panel.`); - } - // If user manually pinned, keep the panel pinned - } - - this.updateControlStates(); - this.updatePinnedState(); // Update visual class - - // Toggle BOTH the visual visibility AND the rendering - // This fully disables the offscreen renderer when preview is hidden - const magnifyGlass = (window as any).comfyUIMagnifyGlass; - if (magnifyGlass && magnifyGlass.setGlassPreviewActive) { - magnifyGlass.setGlassPreviewActive(this.stateManager.state.isGlassPreviewVisible); - } else if (magnifyGlass && magnifyGlass.ui?.setPreviewVisibility) { - // Fallback for backwards compatibility - magnifyGlass.ui.setPreviewVisibility(this.stateManager.state.isGlassPreviewVisible); - } - break; - case 'popout': - // Toggle the pop-out tab - const glass = (window as any).comfyUIMagnifyGlass; - if (glass && glass.popOutManager) { - glass.popOutManager.toggle(); - } - break; - case 'drag-glass': - // Toggle glass drag mode - const mglass = (window as any).comfyUIMagnifyGlass; - if (mglass && mglass.state) { - mglass.state.isDragModeEnabled = !mglass.state.isDragModeEnabled; - this.updateControlStates(); - - // Update UI to indicate drag mode - if (mglass.ui && mglass.ui.setDragMode) { - mglass.ui.setDragMode(mglass.state.isDragModeEnabled); - } - } - break; - case 'reset-glass': - // Reset glass offsets - const rglass = (window as any).comfyUIMagnifyGlass; - if (rglass) { - rglass.resetOffsets(); - Logger.debug("Reset glass position via hover controls"); - - // Disable drag mode if active - if (rglass.state && rglass.state.isDragModeEnabled) { - rglass.state.isDragModeEnabled = false; - if (rglass.ui && rglass.ui.setDragMode) { - rglass.ui.setDragMode(false); - } - this.updateControlStates(); - } - } - break; - case 'toggle-cursor': - const cglass = (window as any).comfyUIMagnifyGlass; - if (cglass && cglass.config) { - cglass.config.showCursorPreview = !cglass.config.showCursorPreview; - cglass.updateMagnifiedView(); - this.updateControlStates(); - } - break; - case 'toggle-hold': - this.stateManager.toggleHold(); - this.updateControlStates(); - break; - } + const button = (e.target as HTMLElement).closest('button[data-action]') as HTMLButtonElement | null; + if (button) this.handleControlAction(button.dataset.action!); }); - // Set initial layout based on settings - const controlsPosition = String(this.stateManager.state.settings["🔍MagnifyGlass.ControlsPosition"] || "top-right"); - this.updateControlsLayout(controlsPosition); - - // Initial state update + this.updateControlsLayout(String(this.stateManager.state.settings["🔍MagnifyGlass.ControlsPosition"])); this.updateControlStates(); } - /** - * Update control button states. - */ - updateControlStates(): void { - if (!this.elements.controls) return; + private handleControlAction(action: string): void { + const state = this.stateManager.state; + const settings = state.settings; + const glass = window.comfyUIMagnifyGlass as any; - const pinBtn = this.elements.controls.querySelector('[data-action="pin"]') as HTMLButtonElement; - const lockBtn = this.elements.controls.querySelector('[data-action="lock"]') as HTMLButtonElement; - const visibilityBtn = this.elements.controls.querySelector('[data-action="toggle-panel"]') as HTMLButtonElement; - const glassBtn = this.elements.controls.querySelector('[data-action="toggle-glass"]') as HTMLButtonElement; - const isPanelVisible = this.stateManager.state.isPanelVisible; - const isGlassVisible = this.stateManager.state.isGlassPreviewVisible; - - if (pinBtn) { - pinBtn.classList.toggle('active', this.stateManager.state.isPanelPinned); - pinBtn.setAttribute('aria-pressed', String(this.stateManager.state.isPanelPinned)); - pinBtn.title = this.stateManager.state.isPanelPinned ? "Lock Panel" : "Unlock Panel"; - pinBtn.innerHTML = this.stateManager.state.isPanelPinned ? Icons.lock : Icons.unlock; - // Hide unlock button when panel is hidden - pinBtn.style.display = isPanelVisible ? 'flex' : 'none'; - - // Disable lock button when glass is hidden (no point unlocking to follow invisible glass) - if (!isGlassVisible) { - pinBtn.disabled = true; - pinBtn.style.opacity = '0.5'; - pinBtn.title = "Cannot toggle lock when glass preview is hidden"; - } else { - pinBtn.disabled = false; - pinBtn.style.opacity = ''; - pinBtn.title = this.stateManager.state.isPanelPinned ? "Lock Panel Position (Follow Glass)" : "Unlock Panel Position (Drag Inspector)"; + switch (action) { + case 'float': + this.toggleFloating(); + break; + case 'persist': + this.setPersist(!settings["🔍MagnifyGlass.InfoPanelPersist"]); + break; + case 'follow-selection': { + const follow = !settings["🔍MagnifyGlass.InfoPanelFollowSelection"]; + settings["🔍MagnifyGlass.InfoPanelFollowSelection"] = follow; + setSettingValue("🔍MagnifyGlass.InfoPanelFollowSelection", follow); + this.updateControlStates(); + this.onRefresh?.(); + break; } - } - - if (lockBtn) { - // Only show pin button when panel is visible AND unlocked from glass - const showLockBtn = isPanelVisible && this.stateManager.state.isPanelPinned; - lockBtn.style.display = showLockBtn ? 'flex' : 'none'; - lockBtn.classList.toggle('active', this.stateManager.state.isPanelLocked); - lockBtn.setAttribute('aria-pressed', String(this.stateManager.state.isPanelLocked)); - lockBtn.title = this.stateManager.state.isPanelLocked ? "Unpin Panel Position" : "Pin Panel Position"; - lockBtn.disabled = !this.stateManager.state.isPanelPinned; - } - - const persistBtn = this.elements.controls.querySelector('[data-action="persist"]') as HTMLButtonElement; - if (persistBtn) { - const isPersistConfigured = !!this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]; - persistBtn.classList.toggle('active', isPersistConfigured); - persistBtn.setAttribute('aria-pressed', String(isPersistConfigured)); - persistBtn.title = isPersistConfigured ? "Disable Sticky Info (S)" : "Enable Sticky Info (S)"; - persistBtn.style.display = isPanelVisible ? 'flex' : 'none'; - } - - const holdBtn = this.elements.controls.querySelector('[data-action="toggle-hold"]') as HTMLButtonElement; - if (holdBtn) { - const isPersistConfigured = !!this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]; - const isHeld = this.stateManager.state.isInfoHeld; - - // Only show Hold button when Sticky Info (Persist) is enabled AND panel is visible - holdBtn.style.display = (isPanelVisible && isPersistConfigured) ? 'flex' : 'none'; - - holdBtn.classList.toggle('active', isHeld); - holdBtn.setAttribute('aria-pressed', String(isHeld)); - // Toggle icon: Pause (to hold) vs Play (to resume) - holdBtn.innerHTML = isHeld ? Icons.play : Icons.pause; - holdBtn.title = isHeld ? "Resume Info Update (P)" : "Pause Info Update (P)"; - } - - if (visibilityBtn) { - // Active means "Panel is Visible" - visibilityBtn.classList.toggle('active', isPanelVisible); - visibilityBtn.setAttribute('aria-pressed', String(isPanelVisible)); - visibilityBtn.title = isPanelVisible ? "Hide Panel" : "Show Panel"; - - // Disable hide button if glass is hidden (prevent hiding everything) - if (!isGlassVisible) { - visibilityBtn.disabled = true; - visibilityBtn.style.opacity = '0.5'; - visibilityBtn.title = "Cannot hide panel when glass preview is hidden"; - } else { - visibilityBtn.disabled = false; - visibilityBtn.style.opacity = ''; - } - } - - if (glassBtn) { - glassBtn.classList.toggle('active', isGlassVisible); - glassBtn.setAttribute('aria-pressed', String(isGlassVisible)); - glassBtn.title = isGlassVisible ? "Hide Glass Preview" : "Show Glass Preview"; - - // Only show glass toggle button if the inspector panel is visible - glassBtn.style.display = isPanelVisible ? 'flex' : 'none'; - } - - const cursorBtn = this.elements.controls.querySelector('[data-action="toggle-cursor"]') as HTMLButtonElement; - if (cursorBtn) { - const cglass = (window as any).comfyUIMagnifyGlass; - const showCursor = cglass?.config?.showCursorPreview || false; - cursorBtn.classList.toggle('active', showCursor); - cursorBtn.setAttribute('aria-pressed', String(showCursor)); - cursorBtn.title = showCursor ? "Hide Cursor Preview" : "Show Cursor Preview"; - - // Logic: Visible when glass is visible AND: - // 1. Panel is NOT pinned (attached to glass) - original logic - // 2. Sticky Info (Persist) is NOT enabled - user request - const isPersistConfigured = !!this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]; - - cursorBtn.style.display = (isGlassVisible && !this.stateManager.state.isPanelPinned && !isPersistConfigured) ? 'flex' : 'none'; - } - - const dragGlassBtn = this.elements.controls.querySelector('[data-action="drag-glass"]') as HTMLButtonElement; - if (dragGlassBtn) { - const mglass = (window as any).comfyUIMagnifyGlass; - const isDragMode = mglass?.state?.isDragModeEnabled || false; - dragGlassBtn.classList.toggle('active', isDragMode); - dragGlassBtn.setAttribute('aria-pressed', String(isDragMode)); - dragGlassBtn.title = isDragMode ? "Cancel Move Mode (H)" : "Move Glass Position (H)"; - // Only show drag button when glass is visible (even if panel is hidden) - dragGlassBtn.style.display = isGlassVisible ? 'flex' : 'none'; - } - - const resetGlassBtn = this.elements.controls.querySelector('[data-action="reset-glass"]') as HTMLButtonElement; - if (resetGlassBtn) { - // Only show reset button when glass is visible (moves with drag/popout buttons) - resetGlassBtn.style.display = isGlassVisible ? 'flex' : 'none'; - } - - const popoutBtn = this.elements.controls.querySelector('[data-action="popout"]') as HTMLButtonElement; - if (popoutBtn) { - const glass = (window as any).comfyUIMagnifyGlass; - const isOpen = glass?.popOutManager?.isPopOutOpen() || false; - // Logger.debug(`[UIManager] Popout button update - Glass: ${!!glass}, IsOpen: ${isOpen}`); - popoutBtn.classList.toggle('active', isOpen); - popoutBtn.setAttribute('aria-pressed', String(isOpen)); - popoutBtn.title = isOpen ? "Close Pop-out Viewer" : "Open Pop-out Viewer"; + case 'toggle-panel': + if (state.isPanelVisible) { + this.hide(); + // Hiding the panel also ends Sticky + this.setPersist(false); + } else { + this.show(); + } + break; + case 'toggle-glass': + state.isGlassPreviewVisible = !state.isGlassPreviewVisible; + // The panel cannot follow a hidden glass, so it floats where it is + if (!state.isGlassPreviewVisible && !state.isFloating) { + if (state.isPanelVisible) { + const rect = this.elements.panel!.getBoundingClientRect(); + state.floatPosition = { x: rect.left, y: rect.top }; + } + state.isFloating = true; + this.stateManager.saveLayout(); + } + this.updateControlStates(); + // Hiding the preview also stops its rendering + glass?.setGlassPreviewActive(state.isGlassPreviewVisible); + break; + case 'toggle-cursor': + if (glass?.config) { + glass.config.showCursorPreview = !glass.config.showCursorPreview; + setSettingValue("🔍MagnifyGlass.ShowCursorPreview", glass.config.showCursorPreview); + glass.updateMagnifiedView(); + this.updateControlStates(); + } + break; + case 'drag-glass': + if (glass?.state) { + glass.state.isDragModeEnabled = !glass.state.isDragModeEnabled; + glass.ui?.setDragMode?.(glass.state.isDragModeEnabled); + this.updateControlStates(); + } + break; + case 'reset-glass': + if (glass) { + glass.resetOffsets(); + if (glass.state?.isDragModeEnabled) { + glass.state.isDragModeEnabled = false; + glass.ui?.setDragMode?.(false); + this.updateControlStates(); + } + } + break; + case 'popout': + glass?.popOutManager?.toggle(); + break; } } /** - * Update controls layout based on position setting. - * @param position + * Update the toolbar's pressed states, titles and the buttons that cannot act right now. + */ + updateControlStates(): void { + const controls = this.elements.controls; + if (!controls) return; + + const state = this.stateManager.state; + const settings = state.settings; + const glass = window.comfyUIMagnifyGlass as any; + const glassVisible = state.isGlassPreviewVisible; + + const set = (action: string, pressed: boolean, title: string, disabled = false) => { + const button = controls.querySelector(`[data-action="${action}"]`) as HTMLButtonElement; + button.classList.toggle('active', pressed); + button.setAttribute('aria-pressed', String(pressed)); + button.title = title; + button.setAttribute('aria-label', title); + button.disabled = disabled; + }; + + set('float', state.isFloating, + !glassVisible ? 'Floating: the glass preview is hidden' : state.isFloating ? 'Floating: click to follow the glass (U)' : 'Following the glass: click to float, or drag the header (U)', + !glassVisible); + set('persist', !!settings["🔍MagnifyGlass.InfoPanelPersist"], 'Sticky: keep the last node over empty canvas (S)'); + set('follow-selection', !!settings["🔍MagnifyGlass.InfoPanelFollowSelection"], 'Follow selection: show the selected node'); + const keepPanel = !glassVisible && state.isPanelVisible; + set('toggle-panel', state.isPanelVisible, + keepPanel ? 'The panel stays visible while the glass preview is hidden' : state.isPanelVisible ? 'Hide Panel (I)' : 'Show Panel (I)', + keepPanel); + set('toggle-glass', glassVisible, glassVisible ? 'Hide Glass Preview (G)' : 'Show Glass Preview (G)'); + const showCursor = !!glass?.config?.showCursorPreview; + set('toggle-cursor', showCursor, showCursor ? 'Hide Cursor Preview' : 'Show Cursor Preview', !glassVisible); + const isDragMode = !!glass?.state?.isDragModeEnabled; + set('drag-glass', isDragMode, isDragMode ? 'Cancel Move Mode (H)' : 'Move Glass Position (H)', !glassVisible); + (controls.querySelector('[data-action="reset-glass"]') as HTMLButtonElement).disabled = !glassVisible; + const isOpen = !!glass?.popOutManager?.isPopOutOpen(); + set('popout', isOpen, isOpen ? 'Close Pop-out Viewer (Shift+P)' : 'Open Pop-out Viewer (Shift+P)'); + } + + /** + * A row above or below the panel, a column beside it. */ updateControlsLayout(position: string): void { if (!this.elements.controls) return; - // Remove existing layout classes - this.elements.controls.classList.remove('horizontal-layout', 'vertical-layout'); - - // Add appropriate layout class - if (['top', 'bottom'].includes(position)) { - this.elements.controls.classList.add('horizontal-layout'); - } else { - this.elements.controls.classList.add('vertical-layout'); - } - } - - /** - * Load a Google Font dynamically. - * @param fontName - Name of the font to load - */ - loadGoogleFont(fontName: string): void { - // Skip if already loaded - const linkId = `google-font-${fontName.replace(/\s+/g, '-').toLowerCase()}`; - if (document.getElementById(linkId)) return; - - // Create link element for Google Fonts - const link = document.createElement('link'); - link.id = linkId; - link.rel = 'stylesheet'; - link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;500;600;700&display=swap`; - document.head.appendChild(link); - - Logger.debug(`[UIManager] Loaded Google Font: ${fontName}`); + const horizontal = position.startsWith('top') || position.startsWith('bottom'); + this.elements.controls.classList.toggle('horizontal-layout', horizontal); + this.elements.controls.classList.toggle('vertical-layout', !horizontal); } /** @@ -590,79 +430,37 @@ export class UIManager { if (!this.elements.panel) return; const settings = this.stateManager.state.settings; + const style = this.elements.panel.style; - // Apply dimensions - this.elements.panel.style.width = `${settings["🔍MagnifyGlass.InfoPanelWidth"]}px`; - // Set max-height via CSS variable so CSS :hover rules can override - const maxHeight = settings["🔍MagnifyGlass.InfoPanelMaxHeight"]; - this.elements.panel.style.setProperty('--panel-max-height', `${maxHeight}px`); - this.elements.panel.style.height = 'auto'; + style.width = `${settings["🔍MagnifyGlass.InfoPanelWidth"]}px`; + style.setProperty('--panel-max-height', `${settings["🔍MagnifyGlass.InfoPanelMaxHeight"]}px`); + // Opacity applies to the background only, so the text stays readable + style.setProperty('--panel-opacity', `${Number(settings["🔍MagnifyGlass.InfoPanelOpacity"]) || 100}%`); - // 2. Apply static styles (only needed once or if overwritten, but safe to set) - this.elements.panel.style.position = 'absolute'; - this.elements.panel.style.zIndex = '99999'; - this.elements.panel.style.transform = 'translateY(-10px)'; - this.elements.panel.style.pointerEvents = 'auto'; - this.elements.panel.style.userSelect = 'none'; - - // Apply Font Family - const fontFamily = settings["🔍MagnifyGlass.InfoPanelFontFamily"] as string || "System Default"; - if (fontFamily === "System Default" || fontFamily === "system-ui") { - this.elements.panel.style.fontFamily = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; + // Apply Font Family (only fonts installed locally; nothing is downloaded) + const fontFamily = settings["🔍MagnifyGlass.InfoPanelFontFamily"] as string; + if (fontFamily === "System Default") { + style.fontFamily = "inherit"; } else if (fontFamily === "monospace") { - this.elements.panel.style.fontFamily = "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, monospace"; + style.fontFamily = "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, monospace"; } else { - // Load Google Font dynamically - this.loadGoogleFont(fontFamily); - this.elements.panel.style.fontFamily = `'${fontFamily}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`; + style.fontFamily = `'${fontFamily}', Inter, Arial, sans-serif`; + } + style.fontSize = `${settings["🔍MagnifyGlass.InfoPanelFontSize"] || 14}px`; + + // Colours changed from the defaults override the palette's. They go on the root so the menus + // and the node highlight, which live outside the panel, take the accent as well. + const root = document.documentElement.style; + for (const [property, key] of [['--mg-panel-fg', "🔍MagnifyGlass.InfoPanelTextColor"], ['--mg-panel-accent', "🔍MagnifyGlass.InfoPanelAccentColor"]] as const) { + const color = String(settings[key] || ''); + if (color && color.toLowerCase() !== DEFAULT_PANEL_SETTINGS[key].toLowerCase()) { + root.setProperty(property, color); + } else { + root.removeProperty(property); + } } - // Apply Font Size - const fontSize = settings["🔍MagnifyGlass.InfoPanelFontSize"] || 14; - this.elements.panel.style.fontSize = `${fontSize}px`; - - // 3. Apply transition - this.elements.panel.style.transition = 'none'; - - // 4. Apply Custom Colors (CSS Variables) - const textColor = settings["🔍MagnifyGlass.InfoPanelTextColor"] as string | undefined; - if (textColor && typeof textColor === 'string') { - const normalizedTextColor = textColor.startsWith('#') ? textColor : `#${textColor}`; - this.elements.panel.style.setProperty('--info-panel-text-color', normalizedTextColor); - } - - const accentColor = settings["🔍MagnifyGlass.InfoPanelAccentColor"] as string | undefined; - if (accentColor && typeof accentColor === 'string') { - const normalizedAccentColor = accentColor.startsWith('#') ? accentColor : `#${accentColor}`; - this.elements.panel.style.setProperty('--info-panel-accent-color', normalizedAccentColor); - } - - // 5. Apply Opacity - // Logic: If visible, use setting. If hidden, use 0. - // We do NOT touch 'display' here to avoid hiding it if it's currently shown. - // 'display' is managed by show() / hide() methods exclusively. - if (this.stateManager.state.isPanelVisible) { - const opacityPercent = Number(settings["🔍MagnifyGlass.InfoPanelOpacity"]) || 100; - this.elements.panel.style.opacity = (opacityPercent / 100).toString(); - } else { - this.elements.panel.style.opacity = '0'; - } - - // 6. Apply Persist Mode Class - const isPersist = !!settings["🔍MagnifyGlass.InfoPanelPersist"]; - if (isPersist) { - this.elements.panel.classList.add('persist-active'); - } else { - this.elements.panel.classList.remove('persist-active'); - } - - // 7. Apply High Contrast Text Class - const isHighContrastText = !!settings["🔍MagnifyGlass.HighContrastText"]; - if (isHighContrastText) { - this.elements.panel.classList.add('high-contrast-text'); - } else { - this.elements.panel.classList.remove('high-contrast-text'); - } + this.elements.panel.classList.toggle('high-contrast-text', !!settings["🔍MagnifyGlass.HighContrastText"]); } /** @@ -671,21 +469,9 @@ export class UIManager { show(): void { if (!this.elements.panel) return; - // Re-enable pointer events (disabled by hide()) - this.elements.panel.style.pointerEvents = 'auto'; - this.elements.panel.style.display = "block"; - // Restore persist-active class if sticky mode is enabled - // (removed by hide() to prevent CSS !important display override) - if (this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]) { - this.elements.panel.classList.add('persist-active'); - } // Controls are shown by positionFloatingControls() after proper positioning - // Apply user's opacity setting when showing (convert percentage to decimal) - const opacityPercent = Number(this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelOpacity"]) || 100; - this.elements.panel.style.opacity = (opacityPercent / 100).toString(); - // Trigger reflow - this.elements.panel.offsetHeight; - this.elements.panel.classList.add('visible'); + this.elements.panel.style.pointerEvents = 'auto'; + this.elements.panel.style.display = ''; this.stateManager.state.isPanelVisible = true; this.updateControlStates(); } @@ -699,16 +485,14 @@ export class UIManager { // Cleanup active drag controllers, editors, and inline controls // to prevent ghost interactions on hidden panel elements this.cleanupEditors(); + this.renderKey = []; + this.hideDropdown(); - this.elements.panel.classList.remove('visible'); - // Remove persist-active class so its CSS `display: flex !important` - // doesn't override our inline `display: none` - this.elements.panel.classList.remove('persist-active'); - // Disable pointer events as a safety net — prevents cursor changes - // and click interactions even if CSS rules override display this.elements.panel.style.pointerEvents = 'none'; this.elements.panel.style.display = 'none'; this.stateManager.state.isPanelVisible = false; + // A hidden panel gets no mouseleave + this.stateManager.state.isPanelHovered = false; this.updateControlStates(); } @@ -721,213 +505,141 @@ export class UIManager { this.elements.panel.classList.toggle('panel-minimized', this.stateManager.state.isPanelMinimized); const minimizeBtn = this.elements.header.querySelector('.minimize-btn'); if (minimizeBtn) { - minimizeBtn.textContent = this.stateManager.state.isPanelMinimized ? '+' : '−'; + minimizeBtn.innerHTML = this.stateManager.state.isPanelMinimized ? Icons.plus : Icons.minus; (minimizeBtn as HTMLElement).title = this.stateManager.state.isPanelMinimized ? 'Expand Panel' : 'Minimize Panel'; + minimizeBtn.setAttribute('aria-label', (minimizeBtn as HTMLElement).title); minimizeBtn.setAttribute('aria-expanded', String(!this.stateManager.state.isPanelMinimized)); } } - /** - * Update pinned state. - */ - updatePinnedState(): void { - if (!this.elements.panel) return; - - this.elements.panel.classList.toggle('panel-pinned', this.stateManager.state.isPanelPinned); - this.elements.panel.classList.toggle('panel-locked', this.stateManager.state.isPanelLocked); - this.updateControlStates(); - } - - /** - * Update theme class. - * @param newTheme - */ - updateTheme(newTheme: string): void { - if (this.elements.panel) { - // Use [\w-]+ to match hyphenated theme names like milk-white, obsidian-dark - this.elements.panel.className = this.elements.panel.className.replace(/theme-[\w-]+/, `theme-${newTheme.toLowerCase()}`); - - // Reapply opacity setting after theme change - const opacityPercent = Number(this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelOpacity"]) || 100; - this.elements.panel.style.opacity = (opacityPercent / 100).toString(); - } - if (this.elements.controls) { - this.elements.controls.className = this.elements.controls.className.replace(/theme-[\w-]+/, `theme-${newTheme.toLowerCase()}`); - } - } - /** * Display information in the panel. * @param info */ displayInfo(info: any): void { - // Skip re-rendering if user is actively editing a value - // This prevents the editor from being destroyed mid-edit - if (this.activeEditors.size > 0) { - // Only update the header subtitle, don't re-render content - this.updateHeaderSubtitle(info); + // Re-render only when something shown changed, and never under an open editor, menu or drag + const renderKey = this.getRenderKey(info); + if (this.isEditing() || (renderKey.length === this.renderKey.length && renderKey.every((v, i) => Object.is(v, this.renderKey[i])))) { return; } + this.renderKey = renderKey; - const sections = this.buildSections(info); - this.renderSections(sections); - this.updateSectionStates(); - this.updateHeaderSubtitle(info); + this.renderHeader(info.hoveredNode); + this.renderContent(info.hoveredNode); } /** - * Build section data from info object. - * @param info - * @returns + * Show the node's identity, its subgraph path and group, and which navigation applies. + * The header is built once and only updated, so focus stays on its buttons. */ - buildSections(info: any): any[] { - const sections: any[] = []; - const settings = this.stateManager.state.settings; + private renderHeader(node: NodeInfo | null): void { + const panel = this.elements.panel!; + const state = this.stateManager.state; + const find = (selector: string) => panel.querySelector(selector) as T; - // Inspector section - if (settings["🔍MagnifyGlass.ShowInspectorTab"] && info.cursor && info.cursor.canvas) { - const inspectorContent = [ - { label: 'Cursor Canvas', value: `(${Math.round(info.cursor.canvas.x)}, ${Math.round(info.cursor.canvas.y)})` }, - { label: 'Canvas Scale', value: `${(info.canvas.scale * 100).toFixed(1)}%` }, - { label: 'Magnifier Zoom', value: `${info.magnifier.zoomFactor}×` } - ]; + const title = find('.header-title'); + title.textContent = node ? node.title : 'Inspector'; + title.title = node ? node.title : ''; + title.classList.toggle('is-empty', !node); + const id = find('.header-id'); + id.hidden = !node; + id.textContent = node ? `#${node.id}` : ''; + const badge = find('.header-badge'); + badge.textContent = node ? node.type : ''; + badge.title = node ? node.type : ''; + badge.hidden = !node || badge.textContent === node.title; + const copy = find('.panel-header [data-action="copy"]'); + copy.hidden = !node; + copy.dataset.copyValue = node ? `${node.title} #${node.id}` : ''; - sections.push({ - id: 'inspector', - icon: Icons.info, - title: 'Inspector', - content: inspectorContent - }); - } + const locked = state.lockedNodeId !== null; + const lock = find('[data-action="lock-node"]'); + lock.disabled = !node && !locked; + lock.classList.toggle('active', locked); + lock.setAttribute('aria-pressed', String(locked)); + lock.title = locked ? 'Unlock: show the hovered node' : 'Lock to this node'; + lock.setAttribute('aria-label', lock.title); + find('[data-action="focus-node"]').disabled = !node; - // Media section - if (info.media) { - const mediaContent = [ - { label: 'Type', value: info.media.tagName }, - { label: 'Source', value: info.media.src } - ]; + const history = this.history; + const inHistory = history.ids.length > 0 && history.graph === app.canvas?.graph; + const atEntry = history.ids[history.index] === state.currentNodeId; + find('[data-action="back"]').disabled = !inHistory || history.index < (atEntry ? 1 : 0); + find('[data-action="forward"]').disabled = !inHistory || !atEntry || history.index >= history.ids.length - 1; - if (info.media.naturalSize) { - mediaContent.push({ label: 'Natural', value: info.media.naturalSize }); - } - - sections.push({ - id: 'media', - icon: Icons.camera, - title: 'Media', - badge: info.media.tagName, - content: mediaContent - }); - } - - // Node Details section - if (info.hoveredNode) { - const nodeContent: any[] = []; // Initial Node details - nodeContent.push({ - label: 'Title', - value: `${info.hoveredNode.title || 'Untitled'} (#${info.hoveredNode.id})`, - clickable: 'title' - }); - if (info.hoveredNode.executionOrder !== undefined) { - nodeContent.push({ label: 'Exec Order', value: info.hoveredNode.executionOrder, clickable: 'execOrder' }); - } - - // Add Zoom to Node button - nodeContent.push({ - label: 'Location', - value: 'Focus Node', - clickable: 'zoom', - nodeId: info.hoveredNode.id, - specialType: 'focus-node' - }); - - // Add category if available - if (info.hoveredNode.category) { - nodeContent.push({ label: 'Category', value: info.hoveredNode.category, copyable: true }); - } - - // Add python module path if available - if (info.hoveredNode.pythonModule) { - const path = info.hoveredNode.pythonModule.replace(/\./g, '/') + '.py'; - nodeContent.push({ label: 'Path', value: path, copyable: true }); - } - - // Check if this is a complex node that shows all widgets - const nodeType = info.hoveredNode.type ? info.hoveredNode.type.toLowerCase() : ''; - const isSaveNode = nodeType.includes('save') && - !nodeType.includes('checkpoint') && - !nodeType.includes('model') && - !nodeType.includes('preview'); - - const showAllWidgets = info.hoveredNode.type && ( - nodeType.includes('ksampler') || - nodeType.includes('sampler') || - nodeType.includes('k_samplers') || - nodeType.includes('checkpoint') || - nodeType.includes('model') || - nodeType.includes('lora') || - nodeType.includes('controlnet') || - nodeType.includes('advanced') || - nodeType.includes('detailer') || - nodeType.includes('inpaint') || - nodeType.includes('upscale') || - nodeType.includes('clip') || - nodeType.includes('text') || - nodeType.includes('encode') - ) && !isSaveNode; - - if (!showAllWidgets) { - // Add specific extractions for simple nodes - const checkpoint = getCheckpointInfo(info.hoveredNode); - if (checkpoint) { - nodeContent.push({ label: 'Model', value: checkpoint }); - } - - const imgInfo = getImageInfo(info.hoveredNode); - if (imgInfo) { - if (typeof imgInfo === 'string') { - nodeContent.push({ label: 'Image', value: imgInfo }); - } else if (typeof imgInfo === 'object' && imgInfo !== null) { - const imgResult = imgInfo as ImageInfoResult; - nodeContent.push({ label: 'Image Size', value: `${imgResult.width}×${imgResult.height}` }); - if (imgResult.src) { - nodeContent.push({ label: 'Image Source', value: imgResult.src }); - } - } - } - - const textContent = getTextBoxContent(info.hoveredNode); - if (textContent) { - nodeContent.push({ label: 'Text', value: textContent }); - } - } - - const importantParameters = getImportantNodeParameters(info.hoveredNode); - nodeContent.push(...importantParameters); - - sections.push({ - id: 'node', - icon: Icons.box, - title: 'Node', - badge: info.hoveredNode.type, - content: nodeContent - }); - } - - return sections; + const breadcrumb = find('.panel-breadcrumb'); + const path = node?.graphPath.length ? ['Root', ...node.graphPath] : []; + breadcrumb.innerHTML = [ + path.map((name, i) => `${escapeHtml(name)}`).join(''), + node?.group ? `${escapeHtml(node.group)}` : '', + node?.isSubgraph ? '' : '' + ].join(''); + breadcrumb.hidden = !breadcrumb.innerHTML; } /** - * Render sections to the panel. - * @param sections + * Everything the rendered content depends on, compared value by value. */ - renderSections(sections: any[]): void { + private getRenderKey(info: any): unknown[] { + const state = this.stateManager.state; + const key: unknown[] = [ + app.canvas?.graph, + state.settings["🔍MagnifyGlass.InfoPanelPersist"], + state.showsSelection, + state.lockedNodeId, + state.isPanelMinimized + ]; + const node = info.hoveredNode; + if (node) { + key.push(node.id, node.title, node.mode, node.executionOrder, node.group, node.graphPath.join('\n')); + for (const widget of node.widgets) key.push(widget.value, widget.computedDisabled); + for (const input of node.inputs) key.push(input.link); + } + return key; + } + + /** + * Widgets are editable when the shown node cannot change under the cursor on its way to the panel. + */ + private canEdit(): boolean { + const state = this.stateManager.state; + return !!state.settings["🔍MagnifyGlass.InfoPanelPersist"] || state.lockedNodeId !== null || state.showsSelection; + } + + private isEditing(): boolean { + return this.activeEditors.size > 0 || + [...this.activeDragControllers.values()].some(controller => controller.isDragging) || + [...this.activeInlineControls.values()].some(control => control.isOpen()); + } + + /** + * The node's rows: exec order, category and source path, then its widgets. + */ + private buildRows(node: NodeInfo): any[] { + const rows: any[] = []; + if (node.executionOrder !== undefined) { + rows.push({ label: 'Exec Order', value: node.executionOrder }); + } + if (node.category) { + rows.push({ label: 'Category', value: node.category }); + } + if (node.pythonModule) { + rows.push({ label: 'Path', value: node.pythonModule.replace(/\./g, '/') + '.py' }); + } + rows.push(...getImportantNodeParameters(node)); + return rows; + } + + /** + * Render the node's rows, or the empty state when no node is shown. + */ + renderContent(node: NodeInfo | null): void { if (!this.elements.content) return; // Cleanup existing editors before re-rendering this.cleanupEditors(); - if (sections.length === 0) { + if (!node) { this.elements.content.innerHTML = `
${Icons.mapPin}
@@ -937,34 +649,26 @@ export class UIManager { return; } - // Check if Sticky Info is enabled (editing only works when sticky) - const isStickyEnabled = !!this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelPersist"]; + const canEdit = this.canEdit(); + const rows = this.buildRows(node); + // Set on the copy buttons after rendering, so long values are not repeated in the markup + const copyTexts: string[] = []; - this.elements.content.innerHTML = sections.map(section => ` -
-
- ${section.icon} - ${escapeHtml(section.title)} - ${section.badge ? `${escapeHtml(section.badge)}` : ''} - ${section.id !== 'node' ? `${Icons.chevronRight}` : ''} -
-
-
- ${section.content.map((item: any) => { - let value; - if (item.specialType === 'focus-node') { - value = `${Icons.focus} ${escapeHtml(item.value)}`; - } else { - value = formatValue(item.value, item.label); + this.elements.content.innerHTML = ` +
+ ${rows.map((item: any) => { + const text = String(item.value ?? ''); + const isLong = isLongText(text); + const expandKey = `${node.id}:${item.label}`; + const isExpanded = isLong && this.expandedValues.has(expandKey); + let value = escapeHtml(text); + if (item.isLinked) { + value += 'linked'; } - const valueClass = getValueClass(item.value); - const valueAttributes = getValueAttributes(item.value); - const clickableAttr = item.clickable ? `data-clickable="${item.clickable}"` : ''; const nodeIdAttr = item.nodeId !== undefined ? `data-node-id="${item.nodeId}"` : ''; - const clickableClass = item.clickable ? 'clickable-row' : ''; // Editable widget detection - const isEditable = item.isEditable && isStickyEnabled && item.widgetName && item.nodeId !== undefined; + const isEditable = item.isEditable && canEdit && item.widgetName && item.nodeId !== undefined; const editableClass = isEditable ? 'editable' : ''; // Serialize constraints and rawValue properly (handle boolean specially to avoid "false" -> true coercion) const constraintsJson = isEditable && item.constraints ? escapeHtml(JSON.stringify(item.constraints)) : ''; @@ -976,31 +680,27 @@ export class UIManager { const actionableClass = isActionable ? 'actionable' : ''; const actionableAttrs = isActionable ? `data-actionable="true" data-widget-name="${escapeHtml(item.widgetName)}" data-widget-type="button"` : ''; - // Only show dropdown arrow for actual dropdowns, not actions like zoom - const dropdownIcon = (item.clickable && item.clickable !== 'zoom') ? '▼' : ''; - // Add copy button for copyable items (category, path) - placed as direct child of row for absolute positioning - const copyButton = item.copyable ? `` : ''; + if (!isActionable) copyTexts.push(text); + const copyButton = isActionable ? '' : ``; + const expandButton = isLong ? `` : ''; return ` -
+
+ ${escapeHtml(item.label).replace(/_/g, '_')} + ${value} ${copyButton} - ${escapeHtml(item.label)} - ${value}${dropdownIcon} + ${expandButton}
`; }).join('')} -
-
-
`).join(''); + ${!canEdit && rows.some((item: any) => item.isEditable) ? '' : ''} +
`; - // Add click handlers for clickable rows - this.attachDropdownClickHandlers(); + this.elements.content.querySelectorAll('.copy-btn').forEach((button, i) => { + button.dataset.copyValue = copyTexts[i]; + }); - // Add click handlers for copy buttons - this.attachCopyButtonHandlers(); - - // Add click handlers for editable rows (only when sticky is enabled) - if (isStickyEnabled) { + if (canEdit) { this.attachEditableRowHandlers(); } @@ -1141,11 +841,20 @@ export class UIManager { valueEl.style.display = 'none'; } } else if (editorContainer) { - // Fall back to click-to-edit for text/number types + // Fall back to click-to-edit for text/number types, from the keyboard too + valueEl.tabIndex = 0; + valueEl.setAttribute('role', 'button'); + valueEl.setAttribute('aria-label', `Edit ${rowEl.querySelector('.info-label')!.textContent}`); valueEl.addEventListener('click', (e) => { e.stopPropagation(); this.enterEditMode(rowEl, valueEl, editorContainer); }); + valueEl.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + e.stopPropagation(); + this.enterEditMode(rowEl, valueEl, editorContainer); + }); } // Attach drag controller if widget type supports it and we don't have an inline control @@ -1154,13 +863,10 @@ export class UIManager { const dragController = new DragValueController(rowEl, { nodeId, widgetName, - widgetType, - currentValue: rawValue, constraints, onChange: (value) => { rowEl.dataset.rawValue = String(value); - // Update displayed value - valueEl.textContent = formatWidgetValue(value); + valueEl.textContent = formatWidgetValue(value, constraints.precision); } }); this.activeDragControllers.set(controlKey, dragController); @@ -1252,7 +958,7 @@ export class UIManager { // Get the actual constrained value from the widget (not the unconstrained input value) // This ensures displayed value matches what was actually stored const actualValue = WidgetSyncManager.getWidgetValue(parseInt(nodeId, 10), widgetName); - valueEl.textContent = formatWidgetValue(actualValue ?? editor.getValue()); + valueEl.textContent = formatWidgetValue(actualValue ?? editor.getValue(), JSON.parse(row.dataset.constraints || '{}').precision); editor.destroy(); this.activeEditors.delete(editorKey); } @@ -1265,130 +971,34 @@ export class UIManager { } /** - * Attach click handlers to copy buttons for copying values to clipboard. + * Copy a button's value. 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. */ - private attachCopyButtonHandlers(): void { - if (!this.elements.content) return; - - const copyButtons = this.elements.content.querySelectorAll('.copy-btn'); - copyButtons.forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - e.preventDefault(); - - const button = btn as HTMLButtonElement; - const valueToCopy = button.dataset.copyValue || ''; - - try { - await navigator.clipboard.writeText(valueToCopy); - - // Visual feedback - temporarily change to checkmark - const originalHtml = button.innerHTML; - button.innerHTML = '✓'; - button.classList.add('copied'); - - setTimeout(() => { - button.innerHTML = originalHtml; - button.classList.remove('copied'); - }, 1500); - } catch (err) { - Logger.error('[UIManager] Failed to copy to clipboard:', err); - } - }); - }); - } - - /** - * Attach click handlers to clickable dropdown rows. - */ - attachDropdownClickHandlers(): void { - if (!this.elements.content) return; - - const clickableRows = this.elements.content.querySelectorAll('[data-clickable]'); - clickableRows.forEach(row => { - const clickableType = (row as HTMLElement).dataset.clickable; - - row.addEventListener('click', (e) => { - e.stopPropagation(); - - if (clickableType === 'title') { - this.showTitleDropdown(row as HTMLElement); - } else if (clickableType === 'execOrder') { - this.showExecOrderDropdown(row as HTMLElement); - } else if (clickableType === 'id') { - this.showIdDropdown(row as HTMLElement); - } else if (clickableType === 'zoom') { - const nodeId = (row as HTMLElement).dataset.nodeId; - if (nodeId) { - const app = (window as any).app; - const node = app.graph.getNodeById(parseInt(nodeId)); - if (node && app.canvas) { - app.canvas.centerOnNode(node); - } - } - } - }); - - // Add hover effect - row.addEventListener('mouseenter', () => { - (row as HTMLElement).style.background = 'var(--comfy-input-bg, rgba(255,255,255,0.05))'; - }); - row.addEventListener('mouseleave', () => { - (row as HTMLElement).style.background = ''; - }); - }); - } - - /** - * Update section expansion states. - */ - updateSectionStates(): void { - if (!this.elements.content) return; - - const sections = this.elements.content.querySelectorAll('.info-section'); - sections.forEach(section => { - const sectionId = (section as HTMLElement).dataset.section; - if (!sectionId) return; - - const header = section.querySelector('.section-header'); - const content = section.querySelector('.section-content'); - - if (header && content) { - if (this.stateManager.state.expandedSections.has(sectionId)) { - header.classList.add('expanded'); - content.classList.add('expanded'); - } else { - header.classList.remove('expanded'); - content.classList.remove('expanded'); - } - } - }); - } - - /** - * Update header subtitle. - * @param info - */ - updateHeaderSubtitle(info: any): void { - if (!this.elements.header) return; - - const subtitleElement = this.elements.header.querySelector('.header-subtitle') as HTMLElement; - if (subtitleElement) { - const accentColor = String(this.stateManager.state.settings["🔍MagnifyGlass.InfoPanelAccentColor"] || ''); - if (info.hoveredNode) { - subtitleElement.textContent = `Analyzing: ${info.hoveredNode.title} `; - subtitleElement.style.color = accentColor; - } else if (info.media) { - subtitleElement.textContent = `Media: ${info.media.tagName} `; - subtitleElement.style.color = accentColor; - } else if (info.connection) { - subtitleElement.textContent = `Connection: ${info.connection.type} `; - subtitleElement.style.color = accentColor; - } else { - subtitleElement.textContent = 'Real-time analysis'; - subtitleElement.style.color = ''; + private async copyValue(button: HTMLButtonElement): Promise { + const text = button.dataset.copyValue ?? ''; + 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) { + app.extensionManager.toast.add({ severity: 'error', summary: 'Copy failed', detail: 'The browser blocked access to the clipboard.', life: 3000 }); + return; } } + + button.innerHTML = Icons.check; + button.classList.add('copied'); + setTimeout(() => { + button.innerHTML = Icons.copy; + button.classList.remove('copied'); + }, 1500); } /** @@ -1406,436 +1016,173 @@ export class UIManager { } /** - * Show dropdown with nodes sorted by title. - * @param anchorElement - Element to anchor the dropdown to + * Open the Go to node picker under its button. Typing filters by title, type or #id, + * the arrows and Enter pick, and Escape or a pick returns focus to the button. */ - showTitleDropdown(anchorElement: HTMLElement): void { + showNodePicker(anchor: HTMLElement): void { this.hideDropdown(); - const nodes = this.nodeSelector.getNodesSortedByTitle(); - if (nodes.length === 0) return; + const picker = document.createElement('div'); + picker.className = 'node-selector-dropdown'; + picker.style.fontSize = this.elements.panel!.style.fontSize; + picker.innerHTML = ` + +
+ `; + const filter = picker.querySelector('input')!; + const list = picker.querySelector('.node-picker-list') as HTMLElement; + const empty = picker.querySelector('.node-picker-empty') as HTMLElement; + let entries: NodeListEntry[] = []; + let active = -1; - this.createDropdown(nodes, anchorElement, 'title'); - } - - /** - * Show dropdown with nodes sorted by execution order. - * @param anchorElement - Element to anchor the dropdown to - */ - showExecOrderDropdown(anchorElement: HTMLElement): void { - this.hideDropdown(); - - const nodes = this.nodeSelector.getNodesSortedByExecOrder(); - if (nodes.length === 0) return; - - this.createDropdown(nodes, anchorElement, 'execOrder'); - } - - /** - * Show dropdown with nodes sorted by ID. - * @param anchorElement - Element to anchor the dropdown to - */ - showIdDropdown(anchorElement: HTMLElement): void { - this.hideDropdown(); - - const nodes = this.nodeSelector.getNodesSortedById(); - if (nodes.length === 0) return; - - this.createDropdown(nodes, anchorElement, 'id'); - } - - /** - * Create and show the dropdown. - */ - private createDropdown( - nodes: NodeListEntry[] | NodeExecOrderEntry[], - anchorElement: HTMLElement, - type: 'title' | 'execOrder' | 'id' - ): void { - const dropdown = document.createElement('div'); - dropdown.className = `node-selector-dropdown theme-${this.stateManager.state.currentTheme}`; - // Accessibility attributes - dropdown.setAttribute('role', 'listbox'); - dropdown.setAttribute('tabindex', '-1'); - dropdown.setAttribute('aria-label', type === 'title' ? 'Select Node by Title' : (type === 'execOrder' ? 'Select Node by Execution Order' : 'Select Node by ID')); - - dropdown.style.cssText = ` - position: fixed; - z-index: 100000; - overflow-y: auto; - background: var(--comfy-menu-bg, #2a2a2a); - border: 1px solid var(--border-color, #444); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - min-width: 200px; - outline: none; - `; - - let activeIndex = 0; // Default to first item - - // Build dropdown items - nodes.forEach((node, index) => { - const item = document.createElement('div'); - item.className = 'dropdown-item'; - item.setAttribute('role', 'option'); - item.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false'); - if (index === activeIndex) { - item.classList.add('focused'); - } - - item.style.cssText = ` - padding: 8px 12px; - cursor: pointer; - border-bottom: 1px solid var(--border-color, #333); - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - background: ${index === activeIndex ? 'var(--comfy-input-bg, #3a3a3a)' : ''}; - `; - - // Check if this is an exec order entry - const isExecOrder = 'order' in node; - - if (isExecOrder) { - const execNode = node as NodeExecOrderEntry; - item.innerHTML = ` - #${execNode.order} - ${escapeHtml(execNode.title)} (#${execNode.id}) - ${escapeHtml(execNode.type)} - `; + const setActive = (index: number, scroll = true) => { + list.children[active]?.classList.remove('active'); + active = entries.length ? Math.max(0, Math.min(index, entries.length - 1)) : -1; + const item = list.children[active] as HTMLElement | undefined; + if (item) { + item.classList.add('active'); + if (scroll) item.scrollIntoView({ block: 'nearest' }); + filter.setAttribute('aria-activedescendant', item.id); } else { - item.innerHTML = ` - ${escapeHtml(node.title)} (#${node.id}) - ${escapeHtml(node.type)} - `; + filter.removeAttribute('aria-activedescendant'); } + }; - // Hover effect - also update active state for keyboard compatibility - item.addEventListener('mouseenter', () => { - // Update visual state of previously active item - if (activeIndex !== index) { - const items = dropdown.querySelectorAll('.dropdown-item'); - if (items[activeIndex]) { - const prev = items[activeIndex] as HTMLElement; - prev.style.background = ''; - prev.classList.remove('focused'); - prev.setAttribute('aria-selected', 'false'); - } - activeIndex = index; - item.classList.add('focused'); - item.setAttribute('aria-selected', 'true'); - } - item.style.background = 'var(--comfy-input-bg, #3a3a3a)'; - }); - item.addEventListener('mouseleave', () => { - // Don't clear background if it's the active item (keep it focused) - if (index !== activeIndex) { - item.style.background = ''; - } - }); + const render = () => { + const current = this.stateManager.state.currentNodeId; + entries = this.nodeSelector.search(filter.value); + list.innerHTML = entries.map((node, i) => ` + `).join(''); + empty.hidden = entries.length > 0; + active = -1; + // Unfiltered, it opens on the current node + setActive(filter.value ? 0 : entries.findIndex(node => node.id === current)); + }; - // Selection handler - item.addEventListener('click', (e) => { - e.stopPropagation(); - this.hideDropdown(); - // cleanup() is called by hideDropdown + const close = (refocus = true) => { + this.hideDropdown(); + if (refocus) anchor.focus(); + }; + const pick = (index: number, refocus = true) => { + const entry = entries[index]; + if (!entry) return; + close(refocus); + this.jumpToNode(entry.id); + }; - // Set selected node in state - this.stateManager.setSelectedNode(node.id); - - // Notify callback if set - if (this.onNodeSelected) { - this.onNodeSelected(node.id); - } - }); - - dropdown.appendChild(item); + filter.addEventListener('input', render); + filter.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + setActive(active + 1); + } else if (e.key === 'ArrowUp') { + setActive(active - 1); + } else if (e.key === 'Enter') { + pick(active); + } else if (e.key === 'Escape') { + close(); + } else { + return; + } + e.preventDefault(); + e.stopPropagation(); + }); + // Keep focus in the filter while clicking an item + list.addEventListener('mousedown', (e) => e.preventDefault()); + list.addEventListener('mouseover', (e) => { + const item = (e.target as HTMLElement).closest('.dropdown-item') as HTMLElement | null; + if (item) setActive(Number(item.dataset.index), false); + }); + list.addEventListener('click', (e) => { + const item = (e.target as HTMLElement).closest('.dropdown-item') as HTMLElement | null; + if (item) pick(Number(item.dataset.index), false); + }); + picker.addEventListener('focusout', (e: FocusEvent) => { + if (e.relatedTarget && !picker.contains(e.relatedTarget as Node)) this.hideDropdown(); }); - // Position dropdown within viewport bounds - document.body.appendChild(dropdown); - this.positionDropdownWithinViewport(dropdown, anchorElement); - - this.currentDropdown = dropdown; - - // Cleanup function to remove all listeners - const cleanup = () => { - document.removeEventListener('mousedown', closeHandler, true); - window.removeEventListener('keydown', keyHandler, true); // Match window listener - if (this.elements.panel) { - this.elements.panel.removeEventListener('mousedown', panelCloseHandler, true); - } - // Remove canvas listener - const canvas = document.querySelector('canvas.main-canvas, canvas') as HTMLCanvasElement; - if (canvas) { - canvas.removeEventListener('pointerdown', canvasCloseHandler, true); - } - // Clear the global reference - if (this.currentDropdownCleanup === cleanup) { - this.currentDropdownCleanup = null; - } + // Capture, so a click on the canvas closes it before LiteGraph handles the pointer + const onPointerDown = (e: PointerEvent) => { + if (!picker.contains(e.target as Node) && !anchor.contains(e.target as Node)) this.hideDropdown(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + this.currentDropdownCleanup = () => { + document.removeEventListener('pointerdown', onPointerDown, true); + anchor.setAttribute('aria-expanded', 'false'); }; - // Store cleanup globally so hideDropdown can call it - this.currentDropdownCleanup = cleanup; - - // Close on click outside - use mousedown with capture to ensure it fires before other handlers - const closeHandler = (e: MouseEvent) => { - if (!dropdown.contains(e.target as Node) && !anchorElement.contains(e.target as Node)) { - this.hideDropdown(); - // Cleanup is now handled by hideDropdown calling this.currentDropdownCleanup - } - }; - - // Close when clicking within the panel but outside dropdown - const panelCloseHandler = (e: MouseEvent) => { - if (!dropdown.contains(e.target as Node) && !anchorElement.contains(e.target as Node)) { - this.hideDropdown(); - // Cleanup is now handled by hideDropdown calling this.currentDropdownCleanup - } - }; - - // Close when clicking on the canvas (canvas intercepts events before document receives them) - const canvasCloseHandler = (_e: PointerEvent) => { - this.hideDropdown(); - }; - - // Helper to update active item - const updateActiveItem = (newIndex: number) => { - const items = dropdown.querySelectorAll('.dropdown-item'); - if (items.length === 0) return; - - // Clamp index - if (newIndex < 0) newIndex = 0; - if (newIndex >= items.length) newIndex = items.length - 1; - - // Remove focus from old - if (activeIndex >= 0 && activeIndex < items.length) { - const oldItem = items[activeIndex] as HTMLElement; - oldItem.classList.remove('focused'); - oldItem.setAttribute('aria-selected', 'false'); - oldItem.style.background = ''; - } - - activeIndex = newIndex; - - // Add focus to new - const newItem = items[activeIndex] as HTMLElement; - newItem.classList.add('focused'); - newItem.setAttribute('aria-selected', 'true'); - newItem.style.background = 'var(--comfy-input-bg, #3a3a3a)'; - - // Scroll into view - newItem.scrollIntoView({ block: 'nearest' }); - }; - - // Keyboard handler - works while dropdown is open regardless of focus - // Uses window-level listener with stopImmediatePropagation to intercept before ComfyUI/LiteGraph - const keyHandler = (e: KeyboardEvent) => { - // DEBUG: Log all key events while dropdown is open - console.debug('[DropdownKeyHandler] Key pressed:', e.key, 'activeIndex:', activeIndex); - - // Handle arrow keys and Enter for navigation regardless of focus - // Only Escape should check focus to avoid intercepting when user tabs away - - if (e.key === 'Escape') { - // For Escape, require focus to avoid breaking other components - if (document.activeElement !== dropdown && !dropdown.contains(document.activeElement)) { - return; - } - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - this.hideDropdown(); - return; - } - - // Arrow keys and Enter work while dropdown is visible - if (e.key === 'ArrowDown') { - console.debug('[DropdownKeyHandler] ArrowDown - moving to', activeIndex + 1); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - updateActiveItem(activeIndex + 1); - } else if (e.key === 'ArrowUp') { - console.debug('[DropdownKeyHandler] ArrowUp - moving to', activeIndex - 1); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - updateActiveItem(activeIndex - 1); - } else if (e.key === 'Enter') { - console.debug('[DropdownKeyHandler] Enter - selecting', activeIndex); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - const items = dropdown.querySelectorAll('.dropdown-item'); - if (activeIndex >= 0 && activeIndex < items.length) { - (items[activeIndex] as HTMLElement).click(); - } - } - }; - - // Delay adding the listener to avoid immediate closure - setTimeout(() => { - // Check if dropdown was already closed (cleanup reference changed or nulled) - // or if it was removed from DOM - if (this.currentDropdownCleanup !== cleanup || !dropdown.parentNode) { - return; - } - - document.addEventListener('mousedown', closeHandler, true); // true = capture phase - // Use window-level listener with capture phase to intercept before ComfyUI canvas handlers - window.addEventListener('keydown', keyHandler, true); - - // Also focus the dropdown for accessibility - dropdown.focus(); - - // Also listen on the panel itself with capture phase - if (this.elements.panel) { - this.elements.panel.addEventListener('mousedown', panelCloseHandler, true); - } - - // Listen on canvas for clicks (canvas intercepts pointer events) - const canvas = document.querySelector('canvas.main-canvas, canvas') as HTMLCanvasElement; - if (canvas) { - canvas.addEventListener('pointerdown', canvasCloseHandler, true); - } - }, 10); + document.body.appendChild(picker); + this.currentDropdown = picker; + anchor.setAttribute('aria-expanded', 'true'); + this.positionPicker(picker, anchor); + render(); + filter.focus(); } /** - * Hide the current dropdown. + * Hide the Go to node picker. */ hideDropdown(): void { - // Run cleanup if it exists - if (this.currentDropdownCleanup) { - this.currentDropdownCleanup(); - this.currentDropdownCleanup = null; - } - - if (this.currentDropdown && this.currentDropdown.parentNode) { - this.currentDropdown.parentNode.removeChild(this.currentDropdown); - this.currentDropdown = null; - } + const cleanup = this.currentDropdownCleanup; + this.currentDropdownCleanup = null; + cleanup?.(); + this.currentDropdown?.remove(); + this.currentDropdown = null; } /** - * Position a dropdown within viewport bounds. - * Allows dropdown to expand to fit content, constrained by available space. + * Below the button when there is room, otherwise above it, held by its bottom edge so filtering keeps it in place. + * It lines up with the panel and is as wide. */ - private positionDropdownWithinViewport( - dropdown: HTMLElement, - anchor: HTMLElement - ): void { + private positionPicker(picker: HTMLElement, anchor: HTMLElement): void { const margin = 10; const gap = 4; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const anchorRect = anchor.getBoundingClientRect(); + const rect = anchor.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom - margin - gap; + const spaceAbove = rect.top - margin - gap; - // Temporarily remove constraints to measure natural size - dropdown.style.maxWidth = 'none'; - dropdown.style.maxHeight = 'none'; - const naturalRect = dropdown.getBoundingClientRect(); - - // Calculate available space in each direction - const spaceBelow = viewportHeight - anchorRect.bottom - margin - gap; - const spaceAbove = anchorRect.top - margin - gap; - const spaceRight = viewportWidth - anchorRect.left - margin; - const spaceLeft = anchorRect.right - margin; - - // Vertical positioning: prefer below, flip above if needed - let top: number; - let maxHeight: number; - - if (spaceBelow >= naturalRect.height || spaceBelow >= spaceAbove) { - // Position below anchor - top = anchorRect.bottom + gap; - maxHeight = Math.max(100, spaceBelow); + if (spaceBelow >= 240 || spaceBelow >= spaceAbove) { + picker.style.top = `${rect.bottom + gap}px`; + picker.style.maxHeight = `${Math.max(100, spaceBelow)}px`; } else { - // Position above anchor - maxHeight = Math.max(100, spaceAbove); - top = anchorRect.top - gap - Math.min(naturalRect.height, maxHeight); + picker.style.bottom = `${window.innerHeight - rect.top + gap}px`; + picker.style.maxHeight = `${Math.max(100, spaceAbove)}px`; } - - // Horizontal positioning: prefer left-aligned, shift if needed - let left = anchorRect.left; - let maxWidth: number; - - if (naturalRect.width <= spaceRight) { - // Fits when left-aligned - maxWidth = spaceRight; - } else if (naturalRect.width <= spaceLeft) { - // Right-align to anchor - left = anchorRect.right - naturalRect.width; - maxWidth = spaceLeft; - } else { - // Constrain to available viewport width - left = margin; - maxWidth = viewportWidth - margin * 2; - } - - // Ensure left position is within bounds - left = Math.max(margin, Math.min(left, viewportWidth - margin - naturalRect.width)); - - // Apply final position and constraints - dropdown.style.top = `${Math.max(margin, top)}px`; - dropdown.style.left = `${Math.max(margin, left)}px`; - dropdown.style.maxWidth = `${maxWidth}px`; - dropdown.style.maxHeight = `${maxHeight}px`; + const panelRect = this.elements.panel!.getBoundingClientRect(); + picker.style.width = `${panelRect.width}px`; + picker.style.left = `${Math.max(margin, Math.min(panelRect.left, window.innerWidth - margin - picker.offsetWidth))}px`; } /** - * Setup global hotkeys for the info panel + * Panel hotkeys: * centres the canvas on the node, ←/→ step through execution order. + * They act only while the panel is visible and hovered or focused; otherwise the glass keeps ←/→ for its nudge. */ private setupHotkeys(): void { this.hotkeyHandler = (e: KeyboardEvent) => { - // Don't trigger if user is typing in an input field - const activeElement = document.activeElement; - if (activeElement && ( - activeElement.tagName === 'INPUT' || - activeElement.tagName === 'TEXTAREA' || - (activeElement as HTMLElement).isContentEditable - )) { - return; - } + if (e.ctrlKey || e.metaKey || e.altKey || isUserTyping()) return; + const { panel } = this.elements; + const { isPanelVisible, isPanelHovered } = this.stateManager.state; + if (!panel || !isPanelVisible || !(isPanelHovered || panel.contains(document.activeElement))) return; - // Check for * key (Shift+8 on US keyboard or NumPad *) - Focus Node if (e.key === '*') { - e.preventDefault(); - e.stopPropagation(); this.focusCurrentNode(); - return; - } - - // Left arrow - Previous node in execution order - if (e.key === 'ArrowLeft') { - // Only if panel is visible - if (!this.elements.panel || this.elements.panel.style.display === 'none') { - return; - } - e.preventDefault(); - e.stopPropagation(); + } else if (e.key === 'ArrowLeft') { this.navigateExecOrder(-1); - return; - } - - // Right arrow - Next node in execution order - if (e.key === 'ArrowRight') { - // Only if panel is visible - if (!this.elements.panel || this.elements.panel.style.display === 'none') { - return; - } - e.preventDefault(); - e.stopPropagation(); + } else if (e.key === 'ArrowRight') { this.navigateExecOrder(1); + } else { return; } + e.preventDefault(); }; - // Use capture phase to get the event before other handlers - window.addEventListener('keydown', this.hotkeyHandler, true); + // Capture phase so the glass's document listener sees defaultPrevented and skips its nudge + document.addEventListener('keydown', this.hotkeyHandler, true); } /** @@ -1843,75 +1190,82 @@ export class UIManager { */ private cleanupHotkeys(): void { if (this.hotkeyHandler) { - window.removeEventListener('keydown', this.hotkeyHandler, true); + document.removeEventListener('keydown', this.hotkeyHandler, true); this.hotkeyHandler = null; } } /** - * Focus/center the canvas on the currently displayed node + * Centre the canvas on a node and lock the panel to it, recording the jump for Back. */ - focusCurrentNode(): void { - // Get current node from state or content - const nodeRow = this.elements.content?.querySelector('[data-node-id]') as HTMLElement; - if (!nodeRow) return; + jumpToNode(nodeId: number): void { + const node = this.nodeSelector.getNodeById(nodeId); + if (!node) return; - const nodeId = nodeRow.dataset.nodeId; - if (!nodeId) return; - - const app = (window as any).app; - if (!app?.graph) return; - - const node = app.graph.getNodeById(parseInt(nodeId)); - if (node && app.canvas) { - app.canvas.centerOnNode(node); + this.recordCurrentNode(); + const history = this.history; + if (history.ids[history.index] !== nodeId) { + history.ids.splice(++history.index, Infinity, nodeId); } + this.showNode(node); } /** - * Navigate to previous/next node by execution order + * Back (-1) or Forward (1) through the jumps. Back from a node reached by hovering returns to the last jump. + */ + private goThroughHistory(step: number): void { + this.recordCurrentNode(); + const history = this.history; + const atEntry = history.ids[history.index] === this.stateManager.state.currentNodeId; + const index = atEntry ? history.index + step : (step < 0 ? history.index : -1); + const node = index >= 0 && index < history.ids.length ? this.nodeSelector.getNodeById(history.ids[index]) : null; + if (!node) return; + + history.index = index; + this.showNode(node); + } + + /** + * Add the shown node to the history, so Back returns to it. A new graph on screen starts a new history. + */ + private recordCurrentNode(): void { + const graph = app.canvas?.graph; + if (this.history.graph !== graph) { + this.history = { graph, ids: [], index: -1 }; + } + const history = this.history; + const current = this.stateManager.state.currentNodeId; + if (current !== null && history.ids[history.index] !== current) { + history.ids.splice(++history.index, Infinity, current); + } + } + + private showNode(node: ComfyNode): void { + this.lockTo(node.id); + app.canvas.centerOnNode(node); + this.onRefresh?.(); + } + + /** + * Centre the canvas on the shown node and lock the panel to it. + */ + focusCurrentNode(): void { + const nodeId = this.stateManager.state.currentNodeId; + if (nodeId !== null) this.jumpToNode(nodeId); + } + + /** + * Jump to the previous/next node by execution order, wrapping around. + * From a node without an order, Next starts at the first node and Prev at the last. * @param direction -1 for previous, +1 for next */ navigateExecOrder(direction: number): void { - // Get current node ID from content - const nodeRow = this.elements.content?.querySelector('[data-node-id]') as HTMLElement; - if (!nodeRow) return; - - const currentNodeId = parseInt(nodeRow.dataset.nodeId || '0'); - if (!currentNodeId && currentNodeId !== 0) return; - - // Get nodes sorted by execution order const nodes = this.nodeSelector.getNodesSortedByExecOrder(); if (nodes.length === 0) return; - // Find current node's position in the list - const currentIndex = nodes.findIndex(n => n.id === currentNodeId); - if (currentIndex === -1) { - // Current node not in exec order list, select first node - this.selectNodeById(nodes[0].id); - return; - } - - // Calculate new index with wrapping - let newIndex = currentIndex + direction; - if (newIndex < 0) newIndex = nodes.length - 1; - if (newIndex >= nodes.length) newIndex = 0; - - // Select the new node - this.selectNodeById(nodes[newIndex].id); - } - - /** - * Select a node by ID and update the inspector panel - */ - private selectNodeById(nodeId: number): void { - // Set selected node in state - this.stateManager.setSelectedNode(nodeId); - - // Notify callback if set - if (this.onNodeSelected) { - this.onNodeSelected(nodeId); - } + const index = nodes.findIndex(n => n.id === this.stateManager.state.currentNodeId); + const next = index === -1 ? (direction > 0 ? 0 : nodes.length - 1) : (index + direction + nodes.length) % nodes.length; + this.jumpToNode(nodes[next].id); } cleanup(): void { @@ -1921,6 +1275,8 @@ export class UIManager { // Clean up hotkey listeners this.cleanupHotkeys(); + document.documentElement.style.removeProperty('--mg-panel-fg'); + document.documentElement.style.removeProperty('--mg-panel-accent'); if (this.elements.panel && this.elements.panel.parentNode) { this.elements.panel.parentNode.removeChild(this.elements.panel); diff --git a/src/info-panel/ValueFormatter.ts b/src/info-panel/ValueFormatter.ts index a74e97e..76f2220 100644 --- a/src/info-panel/ValueFormatter.ts +++ b/src/info-panel/ValueFormatter.ts @@ -1,97 +1,33 @@ /** * ComfyUI MagnifyGlass - Value Formatter Utilities - * + * * Pure utility functions for formatting values in the info panel display. - * Extracted from UIManager.ts for better modularity. */ -import { escapeHtml } from '../shared/utils'; - /** - * Format a value for display in the info panel. - * @param value - The value to format - * @param label - Optional label to determine formatting rules - * @returns Formatted string representation + * Format widget value for display, with the widget's own precision for numbers. + * Arrays and objects show as JSON. */ -export function formatValue(value: unknown, label?: string): string { - if (value === null || value === undefined) return ''; - - const str = String(value); - - // Show full text for prompts, text content, model names, and file paths - if (label && ( - label.toLowerCase().includes('text') || - label.toLowerCase().includes('prompt') || - label.toLowerCase().includes('model') || - label.toLowerCase().includes('file') || - label.toLowerCase().includes('conditioning') || - label.toLowerCase().includes('positive') || - label.toLowerCase().includes('negative') - )) { - return escapeHtml(str); - } - - // Show full text for very long values (no truncation) - return escapeHtml(str); -} - -/** - * Get CSS class names for styling a value. - * @param value - The value to get classes for - * @returns CSS class string - */ -export function getValueClass(value: unknown): string { - if (!value) return ''; - - const str = String(value); - const classes: string[] = []; - - // Mark text that might benefit from special styling for readability - if (str.length > 100) { - classes.push('long-text'); - } - - return classes.join(' '); -} - -/** - * Get HTML attributes for a value element. - * @param value - The value to get attributes for - * @returns HTML attribute string - */ -export function getValueAttributes(value: unknown): string { - // Since we show full text now, we don't need title attributes for long text - // Only add title for very long text that might benefit from tooltips - if (!value) return ''; - - const str = String(value); - if (str.length > 500) { // Only for extremely long text - return `title="${escapeHtml(str)}"`; - } - - return ''; -} - -/** - * Format widget value for display. - * @param value - Widget value to format - * @returns Formatted string representation - */ -export function formatWidgetValue(value: unknown): string { +export function formatWidgetValue(value: unknown, precision?: number): string { if (value === null) return 'null'; if (value === undefined) return 'undefined'; - if (typeof value === 'string') { - return value; // Show full text without truncation - } if (typeof value === 'number') { - return Number.isInteger(value) ? value.toString() : value.toFixed(3); - } - if (typeof value === 'boolean') return value.toString(); - if (Array.isArray(value)) { - return `Array(${value.length})`; + return precision === undefined ? String(value) : value.toFixed(precision); } if (typeof value === 'object') { - return 'Object'; + try { + return JSON.stringify(value, null, 2); + } catch { + // Circular values from custom widgets + return Array.isArray(value) ? `Array(${value.length})` : 'Object'; + } } return String(value); } + +/** + * Values this long are clamped to a few lines with an expand toggle. + */ +export function isLongText(text: string): boolean { + return text.length > 200 || text.split('\n').length > 4; +} diff --git a/src/info-panel/widget-editors/DragValueController.ts b/src/info-panel/widget-editors/DragValueController.ts index 2d51127..b1c7aec 100644 --- a/src/info-panel/widget-editors/DragValueController.ts +++ b/src/info-panel/widget-editors/DragValueController.ts @@ -2,7 +2,7 @@ * ComfyUI MagnifyGlass - Drag Value Controller * * Handles horizontal drag gestures on inspector rows for quick value changes. - * Allows users to drag left/right to adjust numbers or cycle through options. + * Allows users to drag left/right to adjust numbers. */ import { WidgetSyncManager, WidgetConstraints } from './WidgetSyncManager'; @@ -14,21 +14,12 @@ import { Logger } from '../../shared/logger'; export interface DragConfig { nodeId: number; widgetName: string; - widgetType: string; - currentValue: unknown; constraints?: WidgetConstraints; onChange?: (value: unknown) => void; } -/** - * Default sensitivity settings - */ -const DRAG_SENSITIVITY = { - // Pixels to drag for one step change (applies to all numeric types) - pixelsPerStep: 10, - // Minimum pixels to trigger option cycle for combos (increased to prevent accidental cycling) - comboThreshold: 60, -}; +// Pixels to drag for one step change +const PIXELS_PER_STEP = 10; /** * Drag Value Controller @@ -37,11 +28,11 @@ const DRAG_SENSITIVITY = { export class DragValueController { private element: HTMLElement; private config: DragConfig; - private isDragging: boolean = false; + isDragging: boolean = false; + private value: unknown = null; private startX: number = 0; private startValue: number = 0; - private startOptionIndex: number = 0; - private accumulatedDelta: number = 0; + private startStep: number = 0; // Bound event handlers for cleanup private boundPointerDown: (e: PointerEvent) => void; @@ -70,23 +61,6 @@ export class DragValueController { this.element.classList.add('draggable'); this.element.addEventListener('pointerdown', this.boundPointerDown); - - // Add visual indicator - // this.addDragIndicator(); - } - - /** - * Add visual drag indicator to the row - */ - private addDragIndicator(): void { - // Check if already has indicator - if (this.element.querySelector('.drag-indicator')) return; - - const indicator = document.createElement('span'); - indicator.className = 'drag-indicator'; - indicator.innerHTML = '⟷'; // Left-right arrow - indicator.setAttribute('aria-hidden', 'true'); - this.element.appendChild(indicator); } /** @@ -115,18 +89,10 @@ export class DragValueController { this.isDragging = true; this.startX = e.clientX; - this.accumulatedDelta = 0; - - // Capture current value - const type = this.config.widgetType.toLowerCase(); - if (type === 'number' || type === 'int' || type === 'float' || type === 'slider') { - this.startValue = Number(this.config.currentValue) || 0; - } else if (type === 'combo') { - const options = this.config.constraints?.options ?? []; - const currentStr = String(this.config.currentValue); - this.startOptionIndex = options.findIndex(opt => String(opt) === currentStr); - if (this.startOptionIndex === -1) this.startOptionIndex = 0; - } + // From the widget, which the editor, an undo or the canvas may have changed since the row was drawn + this.value = WidgetSyncManager.getWidgetValue(this.config.nodeId, this.config.widgetName); + this.startValue = Number(this.value) || 0; + this.startStep = WidgetSyncManager.getStep(this.config.constraints, e); // Add dragging class this.element.classList.add('dragging'); @@ -155,14 +121,14 @@ export class DragValueController { e.preventDefault(); e.stopPropagation(); - const deltaX = e.clientX - this.startX; - const type = this.config.widgetType.toLowerCase(); - - if (type === 'number' || type === 'int' || type === 'float' || type === 'slider') { - this.handleNumberDrag(deltaX); - } else if (type === 'combo') { - this.handleComboDrag(deltaX); + // Holding or releasing Shift/Alt mid-drag continues from the current value at the new step + const step = WidgetSyncManager.getStep(this.config.constraints, e); + if (step !== this.startStep) { + this.startX = e.clientX; + this.startValue = Number(this.value) || 0; + this.startStep = step; } + this.handleNumberDrag(e.clientX - this.startX); } /** @@ -170,64 +136,16 @@ export class DragValueController { * Uses discrete stepping: every N pixels = exactly 1 step change */ private handleNumberDrag(deltaX: number): void { - const constraints = this.config.constraints; - const isInt = this.config.widgetType.toLowerCase() === 'int'; - - // Determine the step size (1 for integers, 0.1 or widget step for floats) - const step = constraints?.step ?? (isInt ? 1 : 0.1); - // Calculate discrete number of steps (truncate, don't round) - const steps = Math.trunc(deltaX / DRAG_SENSITIVITY.pixelsPerStep); + const steps = Math.trunc(deltaX / PIXELS_PER_STEP); + const target = this.startValue + steps * this.startStep; + if (target === this.value) return; - // Calculate new value - let newValue = this.startValue + (steps * step); - - // Apply constraints - if (constraints?.min !== undefined) { - newValue = Math.max(constraints.min, newValue); - } - if (constraints?.max !== undefined) { - newValue = Math.min(constraints.max, newValue); - } - - // Round for display precision - if (isInt) { - newValue = Math.round(newValue); - } else { - const precision = constraints?.precision ?? 2; - newValue = Number(newValue.toFixed(precision)); - } - - // Sync the value - WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, newValue); - this.config.currentValue = newValue; - this.config.onChange?.(newValue); - } - - /** - * Handle combo value drag - cycle through options - */ - private handleComboDrag(deltaX: number): void { - const options = this.config.constraints?.options ?? []; - if (options.length === 0) return; - - this.accumulatedDelta = deltaX; - - // Calculate how many options to move - const optionDelta = Math.floor(Math.abs(this.accumulatedDelta) / DRAG_SENSITIVITY.comboThreshold); - - if (optionDelta > 0) { - const direction = deltaX > 0 ? 1 : -1; - let newIndex = this.startOptionIndex + (optionDelta * direction); - - // Wrap around - newIndex = ((newIndex % options.length) + options.length) % options.length; - - const newValue = options[newIndex]; - WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, newValue); - this.config.currentValue = newValue; - this.config.onChange?.(newValue); - } + // Clamping, rounding and precision happen in the sync; undo is recorded once on release + const result = WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, target, false); + if (!result.success) return; + this.value = result.newValue; + this.config.onChange?.(result.newValue); } /** @@ -263,6 +181,7 @@ export class DragValueController { private endDrag(pointerId?: number): void { this.isDragging = false; this.element.classList.remove('dragging'); + WidgetSyncManager.recordUndo(); // Release pointer capture if we have a pointer ID if (pointerId !== undefined) { @@ -280,25 +199,12 @@ export class DragValueController { this.element.removeEventListener('lostpointercapture', this.boundLostCapture); } - /** - * Update the current value (call when value changes externally) - */ - updateValue(value: unknown): void { - this.config.currentValue = value; - } - /** * Cleanup and remove drag handling */ destroy(): void { this.element.classList.remove('draggable', 'dragging'); - // Remove indicator - const indicator = this.element.querySelector('.drag-indicator'); - if (indicator) { - indicator.remove(); - } - // Remove the pointerdown listener from the element this.element.removeEventListener('pointerdown', this.boundPointerDown); @@ -314,8 +220,6 @@ export class DragValueController { */ static isTypeSupported(widgetType: string): boolean { const type = widgetType.toLowerCase(); - return [ - 'number', 'int', 'float', 'slider', 'combo' - ].includes(type); + return ['int', 'float', 'slider'].includes(type); } } diff --git a/src/info-panel/widget-editors/InlineControlFactory.ts b/src/info-panel/widget-editors/InlineControlFactory.ts index 2adb392..b98c29e 100644 --- a/src/info-panel/widget-editors/InlineControlFactory.ts +++ b/src/info-panel/widget-editors/InlineControlFactory.ts @@ -28,6 +28,8 @@ export interface InlineControlInstance { element: HTMLElement; getValue(): unknown; setValue(value: unknown): void; + /** True while the control holds an open menu that a re-render would close */ + isOpen(): boolean; destroy(): void; } @@ -76,6 +78,7 @@ export class InlineControlFactory { const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'inline-toggle-input'; + checkbox.setAttribute('aria-label', config.widgetName); // Handle boolean coercion properly const boolValue = config.currentValue === true || config.currentValue === 'true'; checkbox.checked = boolValue; @@ -101,6 +104,7 @@ export class InlineControlFactory { element: container, getValue: () => checkbox.checked, setValue: (v) => { checkbox.checked = Boolean(v); }, + isOpen: () => false, destroy: () => { container.remove(); } }; } @@ -118,23 +122,7 @@ export class InlineControlFactory { // Create trigger element that looks like a select const trigger = createDropdownTrigger(currentValue, 'inline-dropdown-trigger'); - trigger.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 14px; - background: rgba(160, 212, 104, 0.12); - border: 1px solid transparent; - border-radius: 6px; - color: var(--info-panel-accent-color, #74b9ff); - cursor: pointer; - min-width: 100px; - max-width: 180px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; - `; + trigger.setAttribute('aria-label', config.widgetName); let activeDropdown: CustomDropdown | null = null; @@ -150,7 +138,6 @@ export class InlineControlFactory { if (activeDropdown) { activeDropdown.hide(); - activeDropdown = null; return; } @@ -172,16 +159,6 @@ export class InlineControlFactory { activeDropdown.show(); }); - // Hover effect - trigger.addEventListener('mouseenter', () => { - trigger.style.background = 'rgba(160, 212, 104, 0.18)'; - trigger.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)'; - }); - trigger.addEventListener('mouseleave', () => { - trigger.style.background = 'rgba(160, 212, 104, 0.12)'; - trigger.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.1)'; - }); - // Keyboard support trigger.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -199,6 +176,7 @@ export class InlineControlFactory { currentValue = String(v); updateDropdownTriggerValue(trigger, currentValue); }, + isOpen: () => activeDropdown !== null, destroy: () => { activeDropdown?.hide(); container.remove(); @@ -227,24 +205,6 @@ export class InlineControlFactory { button.textContent = buttonLabel; button.type = 'button'; - // Style the button to match theme - button.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 13px; - background: var(--info-panel-accent-color, #a0d468); - color: var(--comfy-menu-bg, #1a1a1a); - border: none; - border-radius: 6px; - cursor: pointer; - font-weight: 600; - transition: all 0.2s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - `; - // Stop propagation on container to prevent parent handlers from interfering container.addEventListener('click', (e) => e.stopPropagation()); container.addEventListener('mousedown', (e) => e.stopPropagation()); @@ -255,18 +215,9 @@ export class InlineControlFactory { e.stopPropagation(); e.preventDefault(); - // Visual feedback - button.style.transform = 'scale(0.95)'; - setTimeout(() => { button.style.transform = ''; }, 100); - // Access the widget callback through the node const app = (window as any).app; - if (!app?.graph) { - Logger.warn(`[InlineControl] No app.graph available`); - return; - } - - const node = app.graph.getNodeById(config.nodeId); + const node = app?.canvas?.graph?.getNodeById(config.nodeId); if (!node?.widgets) { Logger.warn(`[InlineControl] Node ${config.nodeId} not found or has no widgets`); return; @@ -285,22 +236,13 @@ export class InlineControlFactory { } }); - // Hover effects - button.addEventListener('mouseenter', () => { - button.style.transform = 'translateY(-1px)'; - button.style.boxShadow = '0 3px 8px rgba(0, 0, 0, 0.25)'; - }); - button.addEventListener('mouseleave', () => { - button.style.transform = ''; - button.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.2)'; - }); - container.appendChild(button); return { element: container, getValue: () => config.currentValue, setValue: () => { /* Buttons don't have editable values */ }, + isOpen: () => false, destroy: () => { container.remove(); } }; } diff --git a/src/info-panel/widget-editors/WidgetEditorFactory.ts b/src/info-panel/widget-editors/WidgetEditorFactory.ts index 8028744..7939d9e 100644 --- a/src/info-panel/widget-editors/WidgetEditorFactory.ts +++ b/src/info-panel/widget-editors/WidgetEditorFactory.ts @@ -6,8 +6,7 @@ */ import { WidgetSyncManager, WidgetConstraints } from './WidgetSyncManager'; -import { Logger } from '../../shared/logger'; -import { CustomDropdown, createDropdownTrigger, updateDropdownTriggerValue } from '../../shared/CustomDropdown'; +import { Icons } from '../../shared/icons'; /** * Configuration for creating a widget editor @@ -46,31 +45,24 @@ export class WidgetEditorFactory { const type = config.widgetType.toLowerCase(); switch (type) { - case 'number': case 'int': case 'float': return this.createNumberEditor(config); - case 'combo': - return this.createComboEditor(config); - - case 'toggle': - case 'boolean': - return this.createBooleanEditor(config); - case 'slider': return this.createSliderEditor(config); - case 'text': - case 'string': - case 'customtext': + case 'multiline': + return this.createTextEditor(config, true); + default: - return this.createTextEditor(config); + return this.createTextEditor(config, false); } } /** - * Create a number input editor + * Create a number input editor. Typing commits on Enter or blur, Escape drops it. + * The arrow keys and steppers step by the widget step (Shift x10, Alt x0.1) and commit at once. */ private static createNumberEditor(config: WidgetEditorConfig): WidgetEditorInstance { const container = document.createElement('div'); @@ -79,84 +71,83 @@ export class WidgetEditorFactory { const input = document.createElement('input'); input.type = 'number'; input.className = 'widget-editor-input'; - input.value = String(config.currentValue ?? 0); + input.setAttribute('aria-label', config.widgetName); - // Apply constraints - if (config.constraints) { - if (typeof config.constraints.min === 'number') { - input.min = String(config.constraints.min); - } - if (typeof config.constraints.max === 'number') { - input.max = String(config.constraints.max); - } - if (typeof config.constraints.step === 'number') { - input.step = String(config.constraints.step); - } - } + const constraints = config.constraints; + const format = (v: unknown) => typeof constraints?.precision === 'number' ? Number(v).toFixed(constraints.precision) : String(v); + input.value = format(config.currentValue ?? 0); + if (typeof constraints?.min === 'number') input.min = String(constraints.min); + if (typeof constraints?.max === 'number') input.max = String(constraints.max); + input.step = String(WidgetSyncManager.getStep(constraints)); // Create stepper buttons const decrementBtn = document.createElement('button'); decrementBtn.className = 'widget-editor-stepper decrement'; - decrementBtn.innerHTML = '−'; + decrementBtn.innerHTML = Icons.minus; decrementBtn.type = 'button'; + decrementBtn.setAttribute('aria-label', 'Decrease'); const incrementBtn = document.createElement('button'); incrementBtn.className = 'widget-editor-stepper increment'; - incrementBtn.innerHTML = '+'; + incrementBtn.innerHTML = Icons.plus; incrementBtn.type = 'button'; + incrementBtn.setAttribute('aria-label', 'Increase'); - // Event handlers - const syncValue = () => { + let committedValue = input.value; + + const commit = () => { + if (input.value === committedValue) return; const numValue = parseFloat(input.value); - if (!isNaN(numValue)) { - WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue); - config.onChange?.(numValue); - } + if (isNaN(numValue)) return; + const result = WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue); + if (!result.success) return; + input.value = format(result.newValue); + committedValue = input.value; + config.onChange?.(result.newValue); + }; + + const nudge = (direction: number, e: MouseEvent | KeyboardEvent) => { + const typed = parseFloat(input.value); + const current = Number(config.currentValue); + const base = !isNaN(typed) ? typed : !isNaN(current) ? current : (constraints?.min ?? 0); + input.value = String(base + direction * WidgetSyncManager.getStep(constraints, e)); + commit(); }; - input.addEventListener('input', syncValue); - input.addEventListener('change', syncValue); input.addEventListener('focus', () => config.onFocus?.()); - input.addEventListener('blur', () => config.onBlur?.()); + // On the container, so leaving from a stepper focused with Tab closes the editor too + container.addEventListener('focusout', () => { + commit(); + config.onBlur?.(); + }); + + // Keep focus in the input, so Enter and Escape still reach it after a stepper click + decrementBtn.addEventListener('mousedown', (e) => e.preventDefault()); + incrementBtn.addEventListener('mousedown', (e) => e.preventDefault()); decrementBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - // Always use step=1 for fine-grained control via stepper buttons - const step = 1; - const currentVal = parseFloat(input.value); - // Handle NaN (empty input) - fall back to current value or min or 0 - const baseValue = isNaN(currentVal) ? (config.constraints?.min ?? Number(config.currentValue) ?? 0) : currentVal; - let newValue = baseValue - step; - // Clamp to constraints - if (config.constraints?.min !== undefined) { - newValue = Math.max(config.constraints.min, newValue); - } - input.value = String(newValue); - syncValue(); + nudge(-1, e); }); incrementBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - // Always use step=1 for fine-grained control via stepper buttons - const step = 1; - const currentVal = parseFloat(input.value); - // Handle NaN (empty input) - fall back to current value or min or 0 - const baseValue = isNaN(currentVal) ? (config.constraints?.min ?? Number(config.currentValue) ?? 0) : currentVal; - let newValue = baseValue + step; - // Clamp to constraints - if (config.constraints?.max !== undefined) { - newValue = Math.min(config.constraints.max, newValue); - } - input.value = String(newValue); - syncValue(); + nudge(1, e); }); - // Handle keyboard - Enter to confirm, Escape to cancel input.addEventListener('keydown', (e) => { - if (e.key === 'Escape' || e.key === 'Enter') { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); + nudge(e.key === 'ArrowUp' ? 1 : -1, e); + } else if (e.key === 'Enter') { + e.preventDefault(); + input.blur(); + } else if (e.key === 'Escape') { + // Drops the typing only; the steps are already committed + e.preventDefault(); + input.value = committedValue; input.blur(); } }); @@ -170,24 +161,26 @@ export class WidgetEditorFactory { getValue: () => parseFloat(input.value), setValue: (v) => { input.value = String(v); }, focus: () => { input.focus(); input.select(); }, - destroy: () => { container.remove(); } + destroy: () => { + commit(); + container.remove(); + } }; } /** - * Create a text input editor + * Create a text editor. Commits on blur, Enter (Ctrl/Cmd+Enter in a textarea) and when destroyed; Escape reverts. */ - private static createTextEditor(config: WidgetEditorConfig): WidgetEditorInstance { + private static createTextEditor(config: WidgetEditorConfig, multiline: boolean): WidgetEditorInstance { const value = String(config.currentValue ?? ''); - const isLongText = value.length > 50 || value.includes('\n'); const container = document.createElement('div'); container.className = 'widget-editor widget-editor-text'; let inputElement: HTMLInputElement | HTMLTextAreaElement; - if (isLongText) { - // Use textarea for long text (prompts) + // A text input would drop newlines, so any value that has them gets a textarea too + if (multiline || value.includes('\n')) { const textarea = document.createElement('textarea'); textarea.className = 'widget-editor-textarea'; textarea.value = value; @@ -213,29 +206,33 @@ export class WidgetEditorFactory { input.value = value; inputElement = input; } + inputElement.setAttribute('aria-label', config.widgetName); - // Event handlers - const syncValue = () => { - WidgetSyncManager.syncWidgetValueDebounced( - config.nodeId, - config.widgetName, - inputElement.value, - 100 - ); - config.onChange?.(inputElement.value); + const startValue = inputElement.value; + let committedValue = startValue; + + const commit = () => { + if (inputElement.value === committedValue) return; + committedValue = inputElement.value; + WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, committedValue); + config.onChange?.(committedValue); }; - inputElement.addEventListener('input', syncValue); inputElement.addEventListener('focus', () => config.onFocus?.()); - inputElement.addEventListener('blur', () => config.onBlur?.()); + inputElement.addEventListener('blur', () => { + commit(); + config.onBlur?.(); + }); inputElement.addEventListener('keydown', (evt) => { const e = evt as KeyboardEvent; if (e.key === 'Escape') { + e.preventDefault(); + inputElement.value = startValue; + commit(); inputElement.blur(); - } - // Allow Enter in textarea, but not in input - if (e.key === 'Enter' && inputElement instanceof HTMLInputElement) { + } else if (e.key === 'Enter' && (inputElement instanceof HTMLInputElement || e.ctrlKey || e.metaKey)) { + e.preventDefault(); inputElement.blur(); } }); @@ -247,151 +244,13 @@ export class WidgetEditorFactory { getValue: () => inputElement.value, setValue: (v) => { inputElement.value = String(v); }, focus: () => { inputElement.focus(); if (inputElement instanceof HTMLInputElement) inputElement.select(); }, - destroy: () => { container.remove(); } - }; - } - - /** - * Create a combo/dropdown editor - * Uses custom dropdown for viewport-aware positioning - */ - private static createComboEditor(config: WidgetEditorConfig): WidgetEditorInstance { - const container = document.createElement('div'); - container.className = 'widget-editor widget-editor-combo'; - - const options = config.constraints?.options ?? []; - let currentValue = String(config.currentValue); - - // Create trigger element that looks like a select - const trigger = createDropdownTrigger(currentValue, 'widget-editor-dropdown-trigger'); - trigger.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 4px 28px 4px 10px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 14px; - color: var(--info-panel-accent-color, #a0d468); - background: rgba(160, 212, 104, 0.12); - border: 1px solid transparent; - border-radius: 6px; - cursor: pointer; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; - `; - - let activeDropdown: CustomDropdown | null = null; - - // Handle trigger click to show dropdown - const showDropdown = () => { - if (activeDropdown) { - activeDropdown.hide(); - activeDropdown = null; - return; - } - - config.onFocus?.(); - - activeDropdown = new CustomDropdown({ - options: options.map(String), - currentValue, - anchor: trigger, - onChange: (value) => { - currentValue = value; - updateDropdownTriggerValue(trigger, value); - WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, value); - config.onChange?.(value); - }, - onClose: () => { - activeDropdown = null; - config.onBlur?.(); - } - }); - activeDropdown.show(); - }; - - trigger.addEventListener('click', showDropdown); - - // Hover effect - trigger.addEventListener('mouseenter', () => { - trigger.style.background = 'rgba(160, 212, 104, 0.18)'; - trigger.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)'; - }); - trigger.addEventListener('mouseleave', () => { - trigger.style.background = 'rgba(160, 212, 104, 0.12)'; - trigger.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.1)'; - }); - - // Keyboard support - trigger.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - showDropdown(); - } else if (e.key === 'Escape' && activeDropdown) { - activeDropdown.hide(); - } - }); - - container.appendChild(trigger); - - return { - element: container, - getValue: () => currentValue, - setValue: (v) => { - currentValue = String(v); - updateDropdownTriggerValue(trigger, currentValue); - }, - focus: () => trigger.focus(), destroy: () => { - activeDropdown?.hide(); + commit(); container.remove(); } }; } - /** - * Create a boolean toggle editor - */ - private static createBooleanEditor(config: WidgetEditorConfig): WidgetEditorInstance { - const container = document.createElement('div'); - container.className = 'widget-editor widget-editor-boolean'; - - const label = document.createElement('label'); - label.className = 'widget-editor-toggle'; - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.className = 'widget-editor-checkbox'; - // Handle boolean coercion properly - string 'false' should be false, not true - const boolValue = config.currentValue === true || config.currentValue === 'true'; - checkbox.checked = boolValue; - - const slider = document.createElement('span'); - slider.className = 'widget-editor-toggle-slider'; - - // Event handlers - checkbox.addEventListener('change', () => { - WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, checkbox.checked); - config.onChange?.(checkbox.checked); - }); - - checkbox.addEventListener('focus', () => config.onFocus?.()); - checkbox.addEventListener('blur', () => config.onBlur?.()); - - label.appendChild(checkbox); - label.appendChild(slider); - container.appendChild(label); - - return { - element: container, - getValue: () => checkbox.checked, - setValue: (v) => { checkbox.checked = Boolean(v); }, - focus: () => checkbox.focus(), - destroy: () => { container.remove(); } - }; - } - /** * Create a slider editor */ @@ -402,13 +261,14 @@ export class WidgetEditorFactory { const slider = document.createElement('input'); slider.type = 'range'; slider.className = 'widget-editor-range'; + slider.setAttribute('aria-label', config.widgetName); slider.value = String(config.currentValue ?? 0); // Apply constraints if (config.constraints) { slider.min = String(config.constraints.min ?? 0); slider.max = String(config.constraints.max ?? 100); - slider.step = String(config.constraints.step ?? 0.01); + slider.step = String(WidgetSyncManager.getStep(config.constraints)); } // Value display @@ -420,11 +280,12 @@ export class WidgetEditorFactory { const syncValue = () => { const numValue = parseFloat(slider.value); valueDisplay.textContent = numValue.toFixed(config.constraints?.precision ?? 2); - WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue); + WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue, false); config.onChange?.(numValue); }; slider.addEventListener('input', syncValue); + slider.addEventListener('change', () => WidgetSyncManager.recordUndo()); slider.addEventListener('focus', () => config.onFocus?.()); slider.addEventListener('blur', () => config.onBlur?.()); @@ -451,18 +312,4 @@ export class WidgetEditorFactory { destroy: () => { container.remove(); } }; } - - /** - * Check if a widget type is supported for editing - */ - static isTypeSupported(widgetType: string): boolean { - const supported = [ - 'number', 'int', 'float', - 'text', 'string', 'customtext', - 'combo', - 'toggle', 'boolean', - 'slider' - ]; - return supported.includes(widgetType.toLowerCase()); - } } diff --git a/src/info-panel/widget-editors/WidgetSyncManager.ts b/src/info-panel/widget-editors/WidgetSyncManager.ts index 161ad2b..73fca18 100644 --- a/src/info-panel/widget-editors/WidgetSyncManager.ts +++ b/src/info-panel/widget-editors/WidgetSyncManager.ts @@ -16,6 +16,7 @@ export interface WidgetConstraints { min?: number; max?: number; step?: number; + round?: number; precision?: number; options?: unknown[]; // For combo widgets } @@ -35,68 +36,46 @@ export interface SyncResult { * Handles bidirectional sync between inspector and canvas nodes */ export class WidgetSyncManager { - private static debounceTimers: Map = new Map(); - private static readonly DEBOUNCE_MS = 50; - /** - * Update a widget value on a node + * Update a widget value on a node in the graph on screen * @param nodeId - The node ID * @param widgetName - The widget name * @param value - The new value to set + * @param recordUndo - Record an undo step now; drags pass false and record once on release * @returns SyncResult indicating success or failure */ - static syncWidgetValue(nodeId: number, widgetName: string, value: unknown): SyncResult { + static syncWidgetValue(nodeId: number, widgetName: string, value: unknown, recordUndo: boolean = true): SyncResult { try { - if (!app?.graph) { - return { success: false, error: 'ComfyUI app not available' }; - } - - const node = app.graph.getNodeById(nodeId); + const node = app?.canvas?.graph?.getNodeById(nodeId); if (!node) { return { success: false, error: `Node ${nodeId} not found` }; } - if (!node.widgets || node.widgets.length === 0) { - return { success: false, error: `Node ${nodeId} has no widgets` }; - } - - const widget = node.widgets.find((w: any) => w.name === widgetName); + const widget = node.widgets?.find((w: any) => w.name === widgetName); if (!widget) { return { success: false, error: `Widget "${widgetName}" not found on node ${nodeId}` }; } const previousValue = widget.value; - - // Apply constraints before setting value const constrainedValue = this.applyConstraints(value, widget); - // Update widget value - widget.value = constrainedValue; - - // Trigger widget callback if it exists (some widgets need this for side effects) - if (typeof widget.callback === 'function') { - try { - widget.callback(constrainedValue, app.canvas, node, [0, 0], null); - } catch (callbackError) { - Logger.warn(`Widget callback failed for ${widgetName}:`, callbackError); - // Continue anyway - the value is set - } + // setValue runs the callback, onWidgetChanged and bumps the graph version, like a canvas edit + if (typeof widget.setValue === 'function') { + widget.setValue(constrainedValue, { node, canvas: app.canvas }); + } else { + widget.value = constrainedValue; + widget.callback?.(constrainedValue, app.canvas, node, app.canvas.graph_mouse); } - // Mark node as dirty to trigger redraw - if (typeof node.setDirtyCanvas === 'function') { - node.setDirtyCanvas(true, true); - } + node.graph?.setDirtyCanvas(true, true); + if (recordUndo) this.recordUndo(); - // Force canvas redraw - this.triggerCanvasRedraw(); - - Logger.debug(`[WidgetSync] Updated ${widgetName} on node ${nodeId}: ${previousValue} → ${constrainedValue}`); + Logger.debug(`[WidgetSync] Updated ${widgetName} on node ${nodeId}: ${previousValue} → ${widget.value}`); return { success: true, previousValue, - newValue: constrainedValue + newValue: widget.value }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -106,56 +85,10 @@ export class WidgetSyncManager { } /** - * Debounced version of syncWidgetValue for text input - * @param nodeId - The node ID - * @param widgetName - The widget name - * @param value - The new value to set - * @param debounceMs - Debounce delay in milliseconds (default 50ms) + * Let ComfyUI's change tracker pick up the edit as an undo step */ - static syncWidgetValueDebounced( - nodeId: number, - widgetName: string, - value: unknown, - debounceMs: number = this.DEBOUNCE_MS - ): void { - const key = `${nodeId}:${widgetName}`; - - // Clear existing timer - const existingTimer = this.debounceTimers.get(key); - if (existingTimer) { - window.clearTimeout(existingTimer); - } - - // Set new timer - const timer = window.setTimeout(() => { - this.syncWidgetValue(nodeId, widgetName, value); - this.debounceTimers.delete(key); - }, debounceMs); - - this.debounceTimers.set(key, timer); - } - - /** - * Get widget constraints for validation - * @param nodeId - The node ID - * @param widgetName - The widget name - * @returns WidgetConstraints or null if widget not found - */ - static getWidgetConstraints(nodeId: number, widgetName: string): WidgetConstraints | null { - try { - if (!app?.graph) return null; - - const node = app.graph.getNodeById(nodeId); - if (!node?.widgets) return null; - - const widget = node.widgets.find((w: any) => w.name === widgetName); - if (!widget) return null; - - return this.extractConstraints(widget); - } catch (error) { - Logger.warn(`[WidgetSync] Error getting constraints:`, error); - return null; - } + static recordUndo(): void { + app?.extensionManager?.workflow?.activeWorkflow?.changeTracker?.checkState(); } /** @@ -170,24 +103,39 @@ export class WidgetSyncManager { if (typeof widget.step === 'number') constraints.step = widget.step; // Options object (ComfyUI sometimes nests these) - if (widget.options) { - if (typeof widget.options.min === 'number') constraints.min = widget.options.min; - if (typeof widget.options.max === 'number') constraints.max = widget.options.max; - if (typeof widget.options.step === 'number') constraints.step = widget.options.step; - if (typeof widget.options.precision === 'number') constraints.precision = widget.options.precision; - if (Array.isArray(widget.options.values)) { - constraints.options = widget.options.values; + const options = widget.options; + if (options && !Array.isArray(options)) { + if (typeof options.min === 'number') constraints.min = options.min; + if (typeof options.max === 'number') constraints.max = options.max; + // ComfyUI stores step as 10x the input step and the real one as step2 + if (typeof options.step2 === 'number') constraints.step = options.step2; + else if (typeof options.step === 'number') constraints.step = options.step / 10; + if (typeof options.round === 'number' && options.round > 0) constraints.round = options.round; + if (typeof options.precision === 'number') constraints.precision = options.precision; + const values = typeof options.values === 'function' ? options.values(widget, widget.node) : options.values; + if (Array.isArray(values)) { + constraints.options = values; } } // Combo widget options (array of values) - if (Array.isArray(widget.options)) { - constraints.options = widget.options; + if (Array.isArray(options)) { + constraints.options = options; } return constraints; } + /** + * Step for one nudge of a number widget. Shift is x10, Alt is x0.1 but never finer than the widget stores. + */ + static getStep(constraints: WidgetConstraints | undefined, e?: { shiftKey: boolean; altKey: boolean }): number { + 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; + } + /** * Apply constraints to a value */ @@ -196,14 +144,20 @@ export class WidgetSyncManager { const widgetType = this.getWidgetType(widget); // Handle number types - if (widgetType === 'number' || widgetType === 'INT' || widgetType === 'FLOAT' || widgetType === 'slider') { + if (widgetType === 'INT' || widgetType === 'FLOAT' || widgetType === 'slider') { let numValue = typeof value === 'number' ? value : parseFloat(String(value)); if (isNaN(numValue)) { return widget.value; // Return current value if invalid } - // Apply min/max + if (constraints.round) { + numValue = Math.round(numValue / constraints.round) * constraints.round; + } + if (typeof constraints.precision === 'number') { + numValue = parseFloat(numValue.toFixed(constraints.precision)); + } + if (typeof constraints.min === 'number') { numValue = Math.max(constraints.min, numValue); } @@ -211,14 +165,6 @@ export class WidgetSyncManager { numValue = Math.min(constraints.max, numValue); } - // Note: Skip step rounding to preserve user's exact input value - // The widget callback or ComfyUI pipeline will handle step constraints if needed - - // Apply precision - if (typeof constraints.precision === 'number') { - numValue = parseFloat(numValue.toFixed(constraints.precision)); - } - return numValue; } @@ -258,12 +204,13 @@ export class WidgetSyncManager { // Button widgets - explicit type check if (type === 'button') return 'button'; - // Map ComfyUI widget types to our categories - if (type === 'number' || type === 'int' || type === 'float') return 'number'; + // Map ComfyUI widget types to our categories. INT and FLOAT both use 'number'; INT has precision 0. + if (type === 'number' || type === 'knob') return widget.options?.precision === 0 ? 'INT' : 'FLOAT'; if (type === 'combo' || type === 'string' && Array.isArray(widget.options)) return 'combo'; if (type === 'toggle' || type === 'boolean') return 'boolean'; if (type === 'slider') return 'slider'; - if (type === 'text' || type === 'string' || type === 'customtext') return 'text'; + if (type === 'customtext' || widget.options?.multiline) return 'multiline'; + if (type === 'text' || type === 'string') return 'text'; // Check for array options (combo indicator) if (Array.isArray(widget.options) && widget.options.length > 0) { @@ -305,80 +252,22 @@ export class WidgetSyncManager { // Widgets marked as readonly if (widget.readonly) return false; - // Check type + // Writing a string over an object value would corrupt it + if (widget.value !== null && typeof widget.value === 'object') return false; + const type = this.getWidgetType(widget); - // Button widgets are actionable, not editable - if (type === 'button') return false; + // DOM widgets own their element; only the multiline textarea maps to a plain value + if (widget.element && type !== 'multiline') return false; - const editableTypes = ['number', 'text', 'combo', 'boolean', 'slider', 'INT', 'FLOAT']; - - return editableTypes.includes(type) || editableTypes.includes(type.toUpperCase()); - } - - /** - * Force canvas redraw after widget update - */ - static triggerCanvasRedraw(): void { - try { - if (app?.canvas) { - if (typeof app.canvas.setDirty === 'function') { - app.canvas.setDirty(true, true); - } - if (typeof app.canvas.draw === 'function') { - app.canvas.draw(true, true); - } - } - } catch (error) { - Logger.warn(`[WidgetSync] Canvas redraw failed:`, error); - } + return ['INT', 'FLOAT', 'text', 'multiline', 'combo', 'boolean', 'slider'].includes(type); } /** * Get current value of a widget */ static getWidgetValue(nodeId: number, widgetName: string): unknown | null { - try { - if (!app?.graph) return null; - - const node = app.graph.getNodeById(nodeId); - if (!node?.widgets) return null; - - const widget = node.widgets.find((w: any) => w.name === widgetName); - return widget?.value ?? null; - } catch (error) { - return null; - } - } - - /** - * Validate a value against widget constraints without applying - */ - static validateValue(nodeId: number, widgetName: string, value: unknown): { valid: boolean; error?: string } { - const constraints = this.getWidgetConstraints(nodeId, widgetName); - if (!constraints) { - return { valid: true }; // No constraints = valid - } - - // Number validation - if (typeof value === 'number') { - if (typeof constraints.min === 'number' && value < constraints.min) { - return { valid: false, error: `Value must be at least ${constraints.min}` }; - } - if (typeof constraints.max === 'number' && value > constraints.max) { - return { valid: false, error: `Value must be at most ${constraints.max}` }; - } - } - - // Combo validation - use string comparison - if (constraints.options) { - const valueStr = String(value); - const hasMatch = constraints.options.some(opt => String(opt) === valueStr); - if (!hasMatch) { - return { valid: false, error: `Invalid option selected` }; - } - } - - return { valid: true }; + const widget = app?.canvas?.graph?.getNodeById(nodeId)?.widgets?.find((w: any) => w.name === widgetName); + return widget?.value ?? null; } } diff --git a/src/magnify-glass/EventHandler.ts b/src/magnify-glass/EventHandler.ts index f25a4c8..1752900 100644 --- a/src/magnify-glass/EventHandler.ts +++ b/src/magnify-glass/EventHandler.ts @@ -106,11 +106,11 @@ export class EventHandler { config.offsetY += stepSize; offsetChanged = true; e.preventDefault(); - } else if (e.key === "ArrowLeft") { + } else if (e.key === "ArrowLeft" && !e.defaultPrevented) { // Prevented when the info panel took it config.offsetX -= stepSize; offsetChanged = true; e.preventDefault(); - } else if (e.key === "ArrowRight") { + } else if (e.key === "ArrowRight" && !e.defaultPrevented) { config.offsetX += stepSize; offsetChanged = true; e.preventDefault(); diff --git a/src/magnify-glass/MagnifyGlass.ts b/src/magnify-glass/MagnifyGlass.ts index 4ab1bd2..e5b9cb1 100644 --- a/src/magnify-glass/MagnifyGlass.ts +++ b/src/magnify-glass/MagnifyGlass.ts @@ -6,7 +6,7 @@ import type { ComfyApp } from '../types/comfyui'; import { findLiteGraphCanvas } from '../shared/utils'; -import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET, INFO_PANEL_ID } from '../shared/constants'; +import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from '../shared/constants'; import { registerGlassSettings, registerAccessibilitySettings } from '../shared/settings'; import { ConfigManager } from './ConfigManager'; import { MagnifierState } from './MagnifierState'; @@ -42,12 +42,6 @@ export class MagnifyGlass { /** Last known mouse position for better initial positioning */ lastKnownMousePosition: { x: number; y: number }; - /** Whether we're currently over media (for info panel) */ - isOverMedia: boolean; - - /** Current media element under cursor */ - currentMediaElement: HTMLImageElement | HTMLVideoElement | null; - /** Render loop ID; the loop runs while the glass is active and visible */ private animationLoopId: number | null; @@ -75,10 +69,6 @@ export class MagnifyGlass { // Last known mouse position for better initial positioning this.lastKnownMousePosition = { x: 0, y: 0 }; - // Media tracking - this.isOverMedia = false; - this.currentMediaElement = null; - this.animationLoopId = null; } @@ -159,22 +149,13 @@ export class MagnifyGlass { /** * Force hide all components including extensions (Info Panel, etc). - * This overcomes the "pinned" state of the info panel when the main tool is deactivated. + * This hides the info panel even while it floats, when the main tool is deactivated. */ private forceHideAllComponents(): void { this.state.active = false; this.stopAnimationLoop(); this.ui.hide(); - // 1. Nuclear option: Direct DOM ID targeting - // This is necessary because in some cases object references might be stale or logic might prevent hiding - const panelEl = document.getElementById(INFO_PANEL_ID); - if (panelEl) { - panelEl.style.display = 'none'; - // Also force opacity 0 just in case transition handles display - panelEl.style.opacity = '0'; - } - // Iterate through all registered extensions and hide them if they have a hide method const extensions = window.comfyUIMagnifyGlassExtensions; if (extensions && extensions.length > 0) { @@ -182,10 +163,6 @@ export class MagnifyGlass { // Check basically any likely property for a UI manager or direct hide method if (extension && extension.uiManager && typeof extension.uiManager.hide === 'function') { extension.uiManager.hide(); - // Explicitly force display none if available to be extra sure - if (extension.uiManager.elements && extension.uiManager.elements.panel) { - extension.uiManager.elements.panel.style.display = 'none'; - } } }); } @@ -354,26 +331,8 @@ export class MagnifyGlass { } } - // Reset inspector panel position if exists - const extensions = window.comfyUIMagnifyGlassExtensions; - if (extensions && extensions.length > 0) { - extensions.forEach((extension: any) => { - if (extension?.stateManager) { - extension.stateManager.state.isPanelPinned = false; - extension.stateManager.state.isPanelLocked = false; - extension.stateManager.state.isAutoPinned = false; - extension.stateManager.state.pinnedPosition = { x: 0, y: 0 }; - extension.stateManager.state.lastPinnedPosition = null; - - if (extension.positionManager) { - extension.positionManager.positionPanel(); - } - if (extension.uiManager?.updateControlStates) { - extension.uiManager.updateControlStates(); - } - } - }); - } + // The inspector follows the glass again + window.comfyUIMagnifyGlassExtensions?.forEach(extension => extension.resetPosition()); this.debugger.log("Reset: Magnify glass and inspector panel positions restored to defaults"); } diff --git a/src/magnify-glass/PopOutManager.ts b/src/magnify-glass/PopOutManager.ts index d02c997..080ae9f 100644 --- a/src/magnify-glass/PopOutManager.ts +++ b/src/magnify-glass/PopOutManager.ts @@ -71,10 +71,6 @@ interface PopOutInfo { magnifier?: { zoomFactor: number; }; - media?: { - tagName: string; - naturalSize?: string; - }; } /** @@ -91,7 +87,8 @@ export class PopOutManager { private lastPongTime: number = 0; private pingInterval: number | null = null; private viewerUrl: string; - private currentTheme: string = 'dark'; // Default theme + // The theme last sent to the viewer, null until it connects + private sentTheme: string | null = null; /** Throttle frame sending to ~30fps */ private lastFrameTime: number = 0; @@ -168,16 +165,19 @@ export class PopOutManager { */ private handleMessage(message: PopOutMessage): void { switch (message.type) { - case 'pong': + case 'pong': { this.lastPongTime = Date.now(); if (!this.isOpen) { this.isOpen = true; + this.sentTheme = null; Logger.debug('[PopOut] Viewer tab connected'); if (this.onStateChange) this.onStateChange(true); - // Send current theme immediately upon connection - this.sendConfig({ theme: this.currentTheme }); } + // The viewer follows the palette's light or dark, which the frontend keeps as body.dark-theme + const theme = document.body.classList.contains('dark-theme') ? 'dark' : 'light'; + if (theme !== this.sentTheme) this.sendConfig({ theme }); break; + } case 'close': this.isOpen = false; this.popOutWindow = null; @@ -203,8 +203,8 @@ export class PopOutManager { // Popout requested to zoom to a node if (typeof message.data === 'number') { const app = (window as any).app; - const node = app.graph.getNodeById(message.data); - if (node && app.canvas) { + const node = app.canvas?.graph?.getNodeById(message.data); + if (node) { app.canvas.centerOnNode(node); } } @@ -258,12 +258,7 @@ export class PopOutManager { } const app = (window as any).app; - if (!app?.graph) { - Logger.warn('[PopOut] No app.graph available for button click'); - return; - } - - const node = app.graph.getNodeById(buttonData.nodeId); + const node = app?.canvas?.graph?.getNodeById(buttonData.nodeId); if (!node?.widgets) { Logger.warn(`[PopOut] Node ${buttonData.nodeId} not found or has no widgets`); return; @@ -373,44 +368,22 @@ export class PopOutManager { } } - /** - * Send configuration to the pop-out viewer. - */ /** * Send configuration to the pop-out viewer. */ sendConfig(config: Partial): void { if (!this.channel) return; - // Merge with current theme if not provided - const finalConfig = { - theme: this.currentTheme, - ...config - }; - - // Update local theme cache if provided in config if (config.theme) { - this.currentTheme = config.theme; + this.sentTheme = config.theme; } this.sendMessage({ type: 'config', - data: finalConfig + data: config }); } - /** - * Update the viewer theme. - * @param theme - Theme name (e.g. 'dark', 'light') - */ - updateTheme(theme: string): void { - if (this.currentTheme === theme) return; - - this.currentTheme = theme; - this.sendConfig({ theme }); - Logger.debug(`[PopOut] Theme updated to: ${theme}`); - } - /** * Send inspector info to the pop-out viewer. * @param info - Inspector panel information @@ -437,7 +410,7 @@ export class PopOutManager { try { // Access nodes from ComfyUI's app object const app = (window as any).app; - const nodes = app?.graph?._nodes || []; + const nodes = app?.canvas?.graph?._nodes || []; let nodeList: { id: number; title: string; type: string; order?: number }[]; @@ -541,17 +514,6 @@ export class PopOutManager { }; } - // Handle media - map from GatheredInfo mediaElement - if (info.mediaElement || info.media) { - const media = info.mediaElement || info.media; - sanitized.media = { - tagName: String(media.tagName || media.type || ''), - naturalSize: media.naturalWidth && media.naturalHeight - ? `${media.naturalWidth}×${media.naturalHeight}` - : media.naturalSize - }; - } - return sanitized; } catch (e) { Logger.error('[PopOut] Failed to sanitize info:', e); diff --git a/src/magnify_info_panel.ts b/src/magnify_info_panel.ts index 5528706..0cea3d1 100644 --- a/src/magnify_info_panel.ts +++ b/src/magnify_info_panel.ts @@ -23,6 +23,10 @@ app.registerExtension({ }; checkDependencies(); + }, + // Undo, redo and loading a workflow replace the nodes without moving the mouse, which drives the other updates + afterConfigureGraph() { + if (window.comfyUIMagnifyGlass?.state.active) window.infoPanelManager?.scheduleInfoUpdate(); } }); diff --git a/src/shared/CustomDropdown.ts b/src/shared/CustomDropdown.ts index 4852883..1be2e4c 100644 --- a/src/shared/CustomDropdown.ts +++ b/src/shared/CustomDropdown.ts @@ -3,132 +3,151 @@ * * A custom dropdown component that stays within viewport bounds. * Replaces native select elements for better positioning control. + * Its filter input holds focus while it is open, so typing never reaches ComfyUI's hotkeys. */ +import { Icons } from './icons'; + export interface CustomDropdownConfig { options: string[]; currentValue: string; anchor: HTMLElement; onChange: (value: string) => void; onClose?: () => void; - theme?: string; } +let dropdownCount = 0; + /** * Custom dropdown that positions itself within viewport bounds. * Use this instead of native +
+ `; + const filter = picker.querySelector("input"); + const list = picker.querySelector(".node-picker-list"); + const empty = picker.querySelector(".node-picker-empty"); + let entries = []; + let active = -1; + const setActive = (index, scroll = true) => { + var _a; + (_a = list.children[active]) == null ? void 0 : _a.classList.remove("active"); + active = entries.length ? Math.max(0, Math.min(index, entries.length - 1)) : -1; + const item = list.children[active]; + if (item) { + item.classList.add("active"); + if (scroll) item.scrollIntoView({ block: "nearest" }); + filter.setAttribute("aria-activedescendant", item.id); } else { - item.innerHTML = ` - ${escapeHtml(node.title)} (#${node.id}) - ${escapeHtml(node.type)} - `; - } - item.addEventListener("mouseenter", () => { - if (activeIndex !== index) { - const items = dropdown.querySelectorAll(".dropdown-item"); - if (items[activeIndex]) { - const prev = items[activeIndex]; - prev.style.background = ""; - prev.classList.remove("focused"); - prev.setAttribute("aria-selected", "false"); - } - activeIndex = index; - item.classList.add("focused"); - item.setAttribute("aria-selected", "true"); - } - item.style.background = "var(--comfy-input-bg, #3a3a3a)"; - }); - item.addEventListener("mouseleave", () => { - if (index !== activeIndex) { - item.style.background = ""; - } - }); - item.addEventListener("click", (e) => { - e.stopPropagation(); - this.hideDropdown(); - this.stateManager.setSelectedNode(node.id); - if (this.onNodeSelected) { - this.onNodeSelected(node.id); - } - }); - dropdown.appendChild(item); - }); - document.body.appendChild(dropdown); - this.positionDropdownWithinViewport(dropdown, anchorElement); - this.currentDropdown = dropdown; - const cleanup = () => { - document.removeEventListener("mousedown", closeHandler, true); - window.removeEventListener("keydown", keyHandler, true); - if (this.elements.panel) { - this.elements.panel.removeEventListener("mousedown", panelCloseHandler, true); - } - const canvas = document.querySelector("canvas.main-canvas, canvas"); - if (canvas) { - canvas.removeEventListener("pointerdown", canvasCloseHandler, true); - } - if (this.currentDropdownCleanup === cleanup) { - this.currentDropdownCleanup = null; + filter.removeAttribute("aria-activedescendant"); } }; - this.currentDropdownCleanup = cleanup; - const closeHandler = (e) => { - if (!dropdown.contains(e.target) && !anchorElement.contains(e.target)) { - this.hideDropdown(); - } + const render = () => { + const current = this.stateManager.state.currentNodeId; + entries = this.nodeSelector.search(filter.value); + list.innerHTML = entries.map((node, i) => ` + `).join(""); + empty.hidden = entries.length > 0; + active = -1; + setActive(filter.value ? 0 : entries.findIndex((node) => node.id === current)); }; - const panelCloseHandler = (e) => { - if (!dropdown.contains(e.target) && !anchorElement.contains(e.target)) { - this.hideDropdown(); - } - }; - const canvasCloseHandler = (_e) => { + const close = (refocus = true) => { this.hideDropdown(); + if (refocus) anchor.focus(); }; - const updateActiveItem = (newIndex) => { - const items = dropdown.querySelectorAll(".dropdown-item"); - if (items.length === 0) return; - if (newIndex < 0) newIndex = 0; - if (newIndex >= items.length) newIndex = items.length - 1; - if (activeIndex >= 0 && activeIndex < items.length) { - const oldItem = items[activeIndex]; - oldItem.classList.remove("focused"); - oldItem.setAttribute("aria-selected", "false"); - oldItem.style.background = ""; - } - activeIndex = newIndex; - const newItem = items[activeIndex]; - newItem.classList.add("focused"); - newItem.setAttribute("aria-selected", "true"); - newItem.style.background = "var(--comfy-input-bg, #3a3a3a)"; - newItem.scrollIntoView({ block: "nearest" }); + const pick = (index, refocus = true) => { + const entry = entries[index]; + if (!entry) return; + close(refocus); + this.jumpToNode(entry.id); }; - const keyHandler = (e) => { - console.debug("[DropdownKeyHandler] Key pressed:", e.key, "activeIndex:", activeIndex); - if (e.key === "Escape") { - if (document.activeElement !== dropdown && !dropdown.contains(document.activeElement)) { - return; - } - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - this.hideDropdown(); - return; - } + filter.addEventListener("input", render); + filter.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { - console.debug("[DropdownKeyHandler] ArrowDown - moving to", activeIndex + 1); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - updateActiveItem(activeIndex + 1); + setActive(active + 1); } else if (e.key === "ArrowUp") { - console.debug("[DropdownKeyHandler] ArrowUp - moving to", activeIndex - 1); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - updateActiveItem(activeIndex - 1); + setActive(active - 1); } else if (e.key === "Enter") { - console.debug("[DropdownKeyHandler] Enter - selecting", activeIndex); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - const items = dropdown.querySelectorAll(".dropdown-item"); - if (activeIndex >= 0 && activeIndex < items.length) { - items[activeIndex].click(); - } - } - }; - setTimeout(() => { - if (this.currentDropdownCleanup !== cleanup || !dropdown.parentNode) { + pick(active); + } else if (e.key === "Escape") { + close(); + } else { return; } - document.addEventListener("mousedown", closeHandler, true); - window.addEventListener("keydown", keyHandler, true); - dropdown.focus(); - if (this.elements.panel) { - this.elements.panel.addEventListener("mousedown", panelCloseHandler, true); - } - const canvas = document.querySelector("canvas.main-canvas, canvas"); - if (canvas) { - canvas.addEventListener("pointerdown", canvasCloseHandler, true); - } - }, 10); + e.preventDefault(); + e.stopPropagation(); + }); + list.addEventListener("mousedown", (e) => e.preventDefault()); + list.addEventListener("mouseover", (e) => { + const item = e.target.closest(".dropdown-item"); + if (item) setActive(Number(item.dataset.index), false); + }); + list.addEventListener("click", (e) => { + const item = e.target.closest(".dropdown-item"); + if (item) pick(Number(item.dataset.index), false); + }); + picker.addEventListener("focusout", (e) => { + if (e.relatedTarget && !picker.contains(e.relatedTarget)) this.hideDropdown(); + }); + const onPointerDown = (e) => { + if (!picker.contains(e.target) && !anchor.contains(e.target)) this.hideDropdown(); + }; + document.addEventListener("pointerdown", onPointerDown, true); + this.currentDropdownCleanup = () => { + document.removeEventListener("pointerdown", onPointerDown, true); + anchor.setAttribute("aria-expanded", "false"); + }; + document.body.appendChild(picker); + this.currentDropdown = picker; + anchor.setAttribute("aria-expanded", "true"); + this.positionPicker(picker, anchor); + render(); + filter.focus(); } /** - * Hide the current dropdown. + * Hide the Go to node picker. */ hideDropdown() { - if (this.currentDropdownCleanup) { - this.currentDropdownCleanup(); - this.currentDropdownCleanup = null; - } - if (this.currentDropdown && this.currentDropdown.parentNode) { - this.currentDropdown.parentNode.removeChild(this.currentDropdown); - this.currentDropdown = null; - } + var _a; + const cleanup = this.currentDropdownCleanup; + this.currentDropdownCleanup = null; + cleanup == null ? void 0 : cleanup(); + (_a = this.currentDropdown) == null ? void 0 : _a.remove(); + this.currentDropdown = null; } /** - * Position a dropdown within viewport bounds. - * Allows dropdown to expand to fit content, constrained by available space. + * Below the button when there is room, otherwise above it, held by its bottom edge so filtering keeps it in place. + * It lines up with the panel and is as wide. */ - positionDropdownWithinViewport(dropdown, anchor) { + positionPicker(picker, anchor) { const margin = 10; const gap = 4; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const anchorRect = anchor.getBoundingClientRect(); - dropdown.style.maxWidth = "none"; - dropdown.style.maxHeight = "none"; - const naturalRect = dropdown.getBoundingClientRect(); - const spaceBelow = viewportHeight - anchorRect.bottom - margin - gap; - const spaceAbove = anchorRect.top - margin - gap; - const spaceRight = viewportWidth - anchorRect.left - margin; - const spaceLeft = anchorRect.right - margin; - let top; - let maxHeight; - if (spaceBelow >= naturalRect.height || spaceBelow >= spaceAbove) { - top = anchorRect.bottom + gap; - maxHeight = Math.max(100, spaceBelow); + const rect = anchor.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom - margin - gap; + const spaceAbove = rect.top - margin - gap; + if (spaceBelow >= 240 || spaceBelow >= spaceAbove) { + picker.style.top = `${rect.bottom + gap}px`; + picker.style.maxHeight = `${Math.max(100, spaceBelow)}px`; } else { - maxHeight = Math.max(100, spaceAbove); - top = anchorRect.top - gap - Math.min(naturalRect.height, maxHeight); + picker.style.bottom = `${window.innerHeight - rect.top + gap}px`; + picker.style.maxHeight = `${Math.max(100, spaceAbove)}px`; } - let left = anchorRect.left; - let maxWidth; - if (naturalRect.width <= spaceRight) { - maxWidth = spaceRight; - } else if (naturalRect.width <= spaceLeft) { - left = anchorRect.right - naturalRect.width; - maxWidth = spaceLeft; - } else { - left = margin; - maxWidth = viewportWidth - margin * 2; - } - left = Math.max(margin, Math.min(left, viewportWidth - margin - naturalRect.width)); - dropdown.style.top = `${Math.max(margin, top)}px`; - dropdown.style.left = `${Math.max(margin, left)}px`; - dropdown.style.maxWidth = `${maxWidth}px`; - dropdown.style.maxHeight = `${maxHeight}px`; + const panelRect = this.elements.panel.getBoundingClientRect(); + picker.style.width = `${panelRect.width}px`; + picker.style.left = `${Math.max(margin, Math.min(panelRect.left, window.innerWidth - margin - picker.offsetWidth))}px`; } /** - * Setup global hotkeys for the info panel + * Panel hotkeys: * centres the canvas on the node, ←/→ step through execution order. + * They act only while the panel is visible and hovered or focused; otherwise the glass keeps ←/→ for its nudge. */ setupHotkeys() { this.hotkeyHandler = (e) => { - const activeElement = document.activeElement; - if (activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.isContentEditable)) { - return; - } + if (e.ctrlKey || e.metaKey || e.altKey || isUserTyping()) return; + const { panel } = this.elements; + const { isPanelVisible, isPanelHovered } = this.stateManager.state; + if (!panel || !isPanelVisible || !(isPanelHovered || panel.contains(document.activeElement))) return; if (e.key === "*") { - e.preventDefault(); - e.stopPropagation(); this.focusCurrentNode(); - return; - } - if (e.key === "ArrowLeft") { - if (!this.elements.panel || this.elements.panel.style.display === "none") { - return; - } - e.preventDefault(); - e.stopPropagation(); + } else if (e.key === "ArrowLeft") { this.navigateExecOrder(-1); - return; - } - if (e.key === "ArrowRight") { - if (!this.elements.panel || this.elements.panel.style.display === "none") { - return; - } - e.preventDefault(); - e.stopPropagation(); + } else if (e.key === "ArrowRight") { this.navigateExecOrder(1); + } else { return; } + e.preventDefault(); }; - window.addEventListener("keydown", this.hotkeyHandler, true); + document.addEventListener("keydown", this.hotkeyHandler, true); } /** * Remove hotkey listeners */ cleanupHotkeys() { if (this.hotkeyHandler) { - window.removeEventListener("keydown", this.hotkeyHandler, true); + document.removeEventListener("keydown", this.hotkeyHandler, true); this.hotkeyHandler = null; } } /** - * Focus/center the canvas on the currently displayed node + * Centre the canvas on a node and lock the panel to it, recording the jump for Back. */ - focusCurrentNode() { - var _a; - const nodeRow = (_a = this.elements.content) == null ? void 0 : _a.querySelector("[data-node-id]"); - if (!nodeRow) return; - const nodeId = nodeRow.dataset.nodeId; - if (!nodeId) return; - const app = window.app; - if (!(app == null ? void 0 : app.graph)) return; - const node = app.graph.getNodeById(parseInt(nodeId)); - if (node && app.canvas) { - app.canvas.centerOnNode(node); + jumpToNode(nodeId) { + const node = this.nodeSelector.getNodeById(nodeId); + if (!node) return; + this.recordCurrentNode(); + const history = this.history; + if (history.ids[history.index] !== nodeId) { + history.ids.splice(++history.index, Infinity, nodeId); } + this.showNode(node); } /** - * Navigate to previous/next node by execution order + * Back (-1) or Forward (1) through the jumps. Back from a node reached by hovering returns to the last jump. + */ + goThroughHistory(step) { + this.recordCurrentNode(); + const history = this.history; + const atEntry = history.ids[history.index] === this.stateManager.state.currentNodeId; + const index = atEntry ? history.index + step : step < 0 ? history.index : -1; + const node = index >= 0 && index < history.ids.length ? this.nodeSelector.getNodeById(history.ids[index]) : null; + if (!node) return; + history.index = index; + this.showNode(node); + } + /** + * Add the shown node to the history, so Back returns to it. A new graph on screen starts a new history. + */ + recordCurrentNode() { + var _a; + const graph = (_a = app.canvas) == null ? void 0 : _a.graph; + if (this.history.graph !== graph) { + this.history = { graph, ids: [], index: -1 }; + } + const history = this.history; + const current = this.stateManager.state.currentNodeId; + if (current !== null && history.ids[history.index] !== current) { + history.ids.splice(++history.index, Infinity, current); + } + } + showNode(node) { + var _a; + this.lockTo(node.id); + app.canvas.centerOnNode(node); + (_a = this.onRefresh) == null ? void 0 : _a.call(this); + } + /** + * Centre the canvas on the shown node and lock the panel to it. + */ + focusCurrentNode() { + const nodeId = this.stateManager.state.currentNodeId; + if (nodeId !== null) this.jumpToNode(nodeId); + } + /** + * Jump to the previous/next node by execution order, wrapping around. + * From a node without an order, Next starts at the first node and Prev at the last. * @param direction -1 for previous, +1 for next */ navigateExecOrder(direction) { - var _a; - const nodeRow = (_a = this.elements.content) == null ? void 0 : _a.querySelector("[data-node-id]"); - if (!nodeRow) return; - const currentNodeId = parseInt(nodeRow.dataset.nodeId || "0"); - if (!currentNodeId && currentNodeId !== 0) return; const nodes = this.nodeSelector.getNodesSortedByExecOrder(); if (nodes.length === 0) return; - const currentIndex = nodes.findIndex((n) => n.id === currentNodeId); - if (currentIndex === -1) { - this.selectNodeById(nodes[0].id); - return; - } - let newIndex = currentIndex + direction; - if (newIndex < 0) newIndex = nodes.length - 1; - if (newIndex >= nodes.length) newIndex = 0; - this.selectNodeById(nodes[newIndex].id); - } - /** - * Select a node by ID and update the inspector panel - */ - selectNodeById(nodeId) { - this.stateManager.setSelectedNode(nodeId); - if (this.onNodeSelected) { - this.onNodeSelected(nodeId); - } + const index = nodes.findIndex((n) => n.id === this.stateManager.state.currentNodeId); + const next = index === -1 ? direction > 0 ? 0 : nodes.length - 1 : (index + direction + nodes.length) % nodes.length; + this.jumpToNode(nodes[next].id); } cleanup() { this.cleanupEditors(); this.hideDropdown(); this.cleanupHotkeys(); + document.documentElement.style.removeProperty("--mg-panel-fg"); + document.documentElement.style.removeProperty("--mg-panel-accent"); if (this.elements.panel && this.elements.panel.parentNode) { this.elements.panel.parentNode.removeChild(this.elements.panel); } diff --git a/web/info-panel/ValueFormatter.js b/web/info-panel/ValueFormatter.js index addda5b..bff3ee4 100644 --- a/web/info-panel/ValueFormatter.js +++ b/web/info-panel/ValueFormatter.js @@ -1,50 +1,22 @@ -import { escapeHtml } from "../shared/utils.js"; -function formatValue(value, label) { - if (value === null || value === void 0) return ""; - const str = String(value); - if (label && (label.toLowerCase().includes("text") || label.toLowerCase().includes("prompt") || label.toLowerCase().includes("model") || label.toLowerCase().includes("file") || label.toLowerCase().includes("conditioning") || label.toLowerCase().includes("positive") || label.toLowerCase().includes("negative"))) { - return escapeHtml(str); - } - return escapeHtml(str); -} -function getValueClass(value) { - if (!value) return ""; - const str = String(value); - const classes = []; - if (str.length > 100) { - classes.push("long-text"); - } - return classes.join(" "); -} -function getValueAttributes(value) { - if (!value) return ""; - const str = String(value); - if (str.length > 500) { - return `title="${escapeHtml(str)}"`; - } - return ""; -} -function formatWidgetValue(value) { +function formatWidgetValue(value, precision) { if (value === null) return "null"; if (value === void 0) return "undefined"; - if (typeof value === "string") { - return value; - } if (typeof value === "number") { - return Number.isInteger(value) ? value.toString() : value.toFixed(3); - } - if (typeof value === "boolean") return value.toString(); - if (Array.isArray(value)) { - return `Array(${value.length})`; + return precision === void 0 ? String(value) : value.toFixed(precision); } if (typeof value === "object") { - return "Object"; + try { + return JSON.stringify(value, null, 2); + } catch { + return Array.isArray(value) ? `Array(${value.length})` : "Object"; + } } return String(value); } +function isLongText(text) { + return text.length > 200 || text.split("\n").length > 4; +} export { - formatValue, formatWidgetValue, - getValueAttributes, - getValueClass + isLongText }; diff --git a/web/info-panel/widget-editors/DragValueController.js b/web/info-panel/widget-editors/DragValueController.js index e90e55a..efe04af 100644 --- a/web/info-panel/widget-editors/DragValueController.js +++ b/web/info-panel/widget-editors/DragValueController.js @@ -3,21 +3,16 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { WidgetSyncManager } from "./WidgetSyncManager.js"; import { Logger } from "../../shared/logger.js"; -const DRAG_SENSITIVITY = { - // Pixels to drag for one step change (applies to all numeric types) - pixelsPerStep: 10, - // Minimum pixels to trigger option cycle for combos (increased to prevent accidental cycling) - comboThreshold: 60 -}; +const PIXELS_PER_STEP = 10; class DragValueController { constructor(element, config) { __publicField(this, "element"); __publicField(this, "config"); __publicField(this, "isDragging", false); + __publicField(this, "value", null); __publicField(this, "startX", 0); __publicField(this, "startValue", 0); - __publicField(this, "startOptionIndex", 0); - __publicField(this, "accumulatedDelta", 0); + __publicField(this, "startStep", 0); // Bound event handlers for cleanup __publicField(this, "boundPointerDown"); __publicField(this, "boundPointerMove"); @@ -40,22 +35,10 @@ class DragValueController { this.element.classList.add("draggable"); this.element.addEventListener("pointerdown", this.boundPointerDown); } - /** - * Add visual drag indicator to the row - */ - addDragIndicator() { - if (this.element.querySelector(".drag-indicator")) return; - const indicator = document.createElement("span"); - indicator.className = "drag-indicator"; - indicator.innerHTML = "⟷"; - indicator.setAttribute("aria-hidden", "true"); - this.element.appendChild(indicator); - } /** * Handle pointer down - start drag tracking */ onPointerDown(e) { - var _a; if (e.button !== 0) return; const target = e.target; if (target.tagName === "INPUT" || target.tagName === "SELECT" || target.tagName === "BUTTON" || target.closest(".inline-control") || target.closest(".inline-control-container") || target.closest(".info-value") || target.closest(".widget-editor") || target.closest(".widget-editor-container") || target.closest(".widget-editor-stepper") || target.closest(".widget-editor-number") || target.closest(".widget-editor-input")) { @@ -63,16 +46,9 @@ class DragValueController { } this.isDragging = true; this.startX = e.clientX; - this.accumulatedDelta = 0; - const type = this.config.widgetType.toLowerCase(); - if (type === "number" || type === "int" || type === "float" || type === "slider") { - this.startValue = Number(this.config.currentValue) || 0; - } else if (type === "combo") { - const options = ((_a = this.config.constraints) == null ? void 0 : _a.options) ?? []; - const currentStr = String(this.config.currentValue); - this.startOptionIndex = options.findIndex((opt) => String(opt) === currentStr); - if (this.startOptionIndex === -1) this.startOptionIndex = 0; - } + this.value = WidgetSyncManager.getWidgetValue(this.config.nodeId, this.config.widgetName); + this.startValue = Number(this.value) || 0; + this.startStep = WidgetSyncManager.getStep(this.config.constraints, e); this.element.classList.add("dragging"); this.element.setPointerCapture(e.pointerId); document.addEventListener("pointermove", this.boundPointerMove, true); @@ -90,13 +66,13 @@ class DragValueController { if (!this.isDragging) return; e.preventDefault(); e.stopPropagation(); - const deltaX = e.clientX - this.startX; - const type = this.config.widgetType.toLowerCase(); - if (type === "number" || type === "int" || type === "float" || type === "slider") { - this.handleNumberDrag(deltaX); - } else if (type === "combo") { - this.handleComboDrag(deltaX); + const step = WidgetSyncManager.getStep(this.config.constraints, e); + if (step !== this.startStep) { + this.startX = e.clientX; + this.startValue = Number(this.value) || 0; + this.startStep = step; } + this.handleNumberDrag(e.clientX - this.startX); } /** * Handle number value drag @@ -104,45 +80,13 @@ class DragValueController { */ handleNumberDrag(deltaX) { var _a, _b; - const constraints = this.config.constraints; - const isInt = this.config.widgetType.toLowerCase() === "int"; - const step = (constraints == null ? void 0 : constraints.step) ?? (isInt ? 1 : 0.1); - const steps = Math.trunc(deltaX / DRAG_SENSITIVITY.pixelsPerStep); - let newValue = this.startValue + steps * step; - if ((constraints == null ? void 0 : constraints.min) !== void 0) { - newValue = Math.max(constraints.min, newValue); - } - if ((constraints == null ? void 0 : constraints.max) !== void 0) { - newValue = Math.min(constraints.max, newValue); - } - if (isInt) { - newValue = Math.round(newValue); - } else { - const precision = (constraints == null ? void 0 : constraints.precision) ?? 2; - newValue = Number(newValue.toFixed(precision)); - } - WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, newValue); - this.config.currentValue = newValue; - (_b = (_a = this.config).onChange) == null ? void 0 : _b.call(_a, newValue); - } - /** - * Handle combo value drag - cycle through options - */ - handleComboDrag(deltaX) { - var _a, _b, _c; - const options = ((_a = this.config.constraints) == null ? void 0 : _a.options) ?? []; - if (options.length === 0) return; - this.accumulatedDelta = deltaX; - const optionDelta = Math.floor(Math.abs(this.accumulatedDelta) / DRAG_SENSITIVITY.comboThreshold); - if (optionDelta > 0) { - const direction = deltaX > 0 ? 1 : -1; - let newIndex = this.startOptionIndex + optionDelta * direction; - newIndex = (newIndex % options.length + options.length) % options.length; - const newValue = options[newIndex]; - WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, newValue); - this.config.currentValue = newValue; - (_c = (_b = this.config).onChange) == null ? void 0 : _c.call(_b, newValue); - } + const steps = Math.trunc(deltaX / PIXELS_PER_STEP); + const target = this.startValue + steps * this.startStep; + if (target === this.value) return; + const result = WidgetSyncManager.syncWidgetValue(this.config.nodeId, this.config.widgetName, target, false); + if (!result.success) return; + this.value = result.newValue; + (_b = (_a = this.config).onChange) == null ? void 0 : _b.call(_a, result.newValue); } /** * Handle pointer up - end drag @@ -174,6 +118,7 @@ class DragValueController { endDrag(pointerId) { this.isDragging = false; this.element.classList.remove("dragging"); + WidgetSyncManager.recordUndo(); if (pointerId !== void 0) { try { this.element.releasePointerCapture(pointerId); @@ -185,21 +130,11 @@ class DragValueController { document.removeEventListener("pointercancel", this.boundPointerCancel, true); this.element.removeEventListener("lostpointercapture", this.boundLostCapture); } - /** - * Update the current value (call when value changes externally) - */ - updateValue(value) { - this.config.currentValue = value; - } /** * Cleanup and remove drag handling */ destroy() { this.element.classList.remove("draggable", "dragging"); - const indicator = this.element.querySelector(".drag-indicator"); - if (indicator) { - indicator.remove(); - } this.element.removeEventListener("pointerdown", this.boundPointerDown); document.removeEventListener("pointermove", this.boundPointerMove, true); document.removeEventListener("pointerup", this.boundPointerUp, true); @@ -211,13 +146,7 @@ class DragValueController { */ static isTypeSupported(widgetType) { const type = widgetType.toLowerCase(); - return [ - "number", - "int", - "float", - "slider", - "combo" - ].includes(type); + return ["int", "float", "slider"].includes(type); } } export { diff --git a/web/info-panel/widget-editors/InlineControlFactory.js b/web/info-panel/widget-editors/InlineControlFactory.js index 40b9b63..852b9e9 100644 --- a/web/info-panel/widget-editors/InlineControlFactory.js +++ b/web/info-panel/widget-editors/InlineControlFactory.js @@ -37,6 +37,7 @@ class InlineControlFactory { const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.className = "inline-toggle-input"; + checkbox.setAttribute("aria-label", config.widgetName); const boolValue = config.currentValue === true || config.currentValue === "true"; checkbox.checked = boolValue; const slider = document.createElement("span"); @@ -57,6 +58,7 @@ class InlineControlFactory { setValue: (v) => { checkbox.checked = Boolean(v); }, + isOpen: () => false, destroy: () => { container.remove(); } @@ -73,23 +75,7 @@ class InlineControlFactory { const options = ((_a = config.constraints) == null ? void 0 : _a.options) ?? []; let currentValue = String(config.currentValue); const trigger = createDropdownTrigger(currentValue, "inline-dropdown-trigger"); - trigger.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 14px; - background: rgba(160, 212, 104, 0.12); - border: 1px solid transparent; - border-radius: 6px; - color: var(--info-panel-accent-color, #74b9ff); - cursor: pointer; - min-width: 100px; - max-width: 180px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; - `; + trigger.setAttribute("aria-label", config.widgetName); let activeDropdown = null; const stopProp = (e) => e.stopPropagation(); container.addEventListener("click", stopProp); @@ -99,7 +85,6 @@ class InlineControlFactory { e.stopPropagation(); if (activeDropdown) { activeDropdown.hide(); - activeDropdown = null; return; } activeDropdown = new CustomDropdown({ @@ -120,14 +105,6 @@ class InlineControlFactory { }); activeDropdown.show(); }); - trigger.addEventListener("mouseenter", () => { - trigger.style.background = "rgba(160, 212, 104, 0.18)"; - trigger.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.15)"; - }); - trigger.addEventListener("mouseleave", () => { - trigger.style.background = "rgba(160, 212, 104, 0.12)"; - trigger.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)"; - }); trigger.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -142,6 +119,7 @@ class InlineControlFactory { currentValue = String(v); updateDropdownTriggerValue(trigger, currentValue); }, + isOpen: () => activeDropdown !== null, destroy: () => { activeDropdown == null ? void 0 : activeDropdown.hide(); container.remove(); @@ -164,38 +142,15 @@ class InlineControlFactory { } button.textContent = buttonLabel; button.type = "button"; - button.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 13px; - background: var(--info-panel-accent-color, #a0d468); - color: var(--comfy-menu-bg, #1a1a1a); - border: none; - border-radius: 6px; - cursor: pointer; - font-weight: 600; - transition: all 0.2s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - `; container.addEventListener("click", (e) => e.stopPropagation()); container.addEventListener("mousedown", (e) => e.stopPropagation()); container.addEventListener("mouseup", (e) => e.stopPropagation()); button.addEventListener("click", (e) => { + var _a, _b; e.stopPropagation(); e.preventDefault(); - button.style.transform = "scale(0.95)"; - setTimeout(() => { - button.style.transform = ""; - }, 100); const app = window.app; - if (!(app == null ? void 0 : app.graph)) { - Logger.warn(`[InlineControl] No app.graph available`); - return; - } - const node = app.graph.getNodeById(config.nodeId); + const node = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(config.nodeId); if (!(node == null ? void 0 : node.widgets)) { Logger.warn(`[InlineControl] Node ${config.nodeId} not found or has no widgets`); return; @@ -212,20 +167,13 @@ class InlineControlFactory { Logger.warn(`[InlineControl] Widget ${config.widgetName} not found or has no callback`); } }); - button.addEventListener("mouseenter", () => { - button.style.transform = "translateY(-1px)"; - button.style.boxShadow = "0 3px 8px rgba(0, 0, 0, 0.25)"; - }); - button.addEventListener("mouseleave", () => { - button.style.transform = ""; - button.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.2)"; - }); container.appendChild(button); return { element: container, getValue: () => config.currentValue, setValue: () => { }, + isOpen: () => false, destroy: () => { container.remove(); } diff --git a/web/info-panel/widget-editors/WidgetEditorFactory.js b/web/info-panel/widget-editors/WidgetEditorFactory.js index e801df7..96137a9 100644 --- a/web/info-panel/widget-editors/WidgetEditorFactory.js +++ b/web/info-panel/widget-editors/WidgetEditorFactory.js @@ -1,5 +1,5 @@ import { WidgetSyncManager } from "./WidgetSyncManager.js"; -import { createDropdownTrigger, updateDropdownTriggerValue, CustomDropdown } from "../../shared/CustomDropdown.js"; +import { Icons } from "../../shared/icons.js"; class WidgetEditorFactory { /** * Create an editor element for the given configuration @@ -7,26 +7,20 @@ class WidgetEditorFactory { static createEditor(config) { const type = config.widgetType.toLowerCase(); switch (type) { - case "number": case "int": case "float": return this.createNumberEditor(config); - case "combo": - return this.createComboEditor(config); - case "toggle": - case "boolean": - return this.createBooleanEditor(config); case "slider": return this.createSliderEditor(config); - case "text": - case "string": - case "customtext": + case "multiline": + return this.createTextEditor(config, true); default: - return this.createTextEditor(config); + return this.createTextEditor(config, false); } } /** - * Create a number input editor + * Create a number input editor. Typing commits on Enter or blur, Escape drops it. + * The arrow keys and steppers step by the widget step (Shift x10, Alt x0.1) and commit at once. */ static createNumberEditor(config) { const container = document.createElement("div"); @@ -34,75 +28,73 @@ class WidgetEditorFactory { const input = document.createElement("input"); input.type = "number"; input.className = "widget-editor-input"; - input.value = String(config.currentValue ?? 0); - if (config.constraints) { - if (typeof config.constraints.min === "number") { - input.min = String(config.constraints.min); - } - if (typeof config.constraints.max === "number") { - input.max = String(config.constraints.max); - } - if (typeof config.constraints.step === "number") { - input.step = String(config.constraints.step); - } - } + input.setAttribute("aria-label", config.widgetName); + const constraints = config.constraints; + const format = (v) => typeof (constraints == null ? void 0 : constraints.precision) === "number" ? Number(v).toFixed(constraints.precision) : String(v); + input.value = format(config.currentValue ?? 0); + if (typeof (constraints == null ? void 0 : constraints.min) === "number") input.min = String(constraints.min); + if (typeof (constraints == null ? void 0 : constraints.max) === "number") input.max = String(constraints.max); + input.step = String(WidgetSyncManager.getStep(constraints)); const decrementBtn = document.createElement("button"); decrementBtn.className = "widget-editor-stepper decrement"; - decrementBtn.innerHTML = "−"; + decrementBtn.innerHTML = Icons.minus; decrementBtn.type = "button"; + decrementBtn.setAttribute("aria-label", "Decrease"); const incrementBtn = document.createElement("button"); incrementBtn.className = "widget-editor-stepper increment"; - incrementBtn.innerHTML = "+"; + incrementBtn.innerHTML = Icons.plus; incrementBtn.type = "button"; - const syncValue = () => { + incrementBtn.setAttribute("aria-label", "Increase"); + let committedValue = input.value; + const commit = () => { var _a; + if (input.value === committedValue) return; const numValue = parseFloat(input.value); - if (!isNaN(numValue)) { - WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue); - (_a = config.onChange) == null ? void 0 : _a.call(config, numValue); - } + if (isNaN(numValue)) return; + const result = WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue); + if (!result.success) return; + input.value = format(result.newValue); + committedValue = input.value; + (_a = config.onChange) == null ? void 0 : _a.call(config, result.newValue); + }; + const nudge = (direction, e) => { + const typed = parseFloat(input.value); + const current = Number(config.currentValue); + const base = !isNaN(typed) ? typed : !isNaN(current) ? current : (constraints == null ? void 0 : constraints.min) ?? 0; + input.value = String(base + direction * WidgetSyncManager.getStep(constraints, e)); + commit(); }; - input.addEventListener("input", syncValue); - input.addEventListener("change", syncValue); input.addEventListener("focus", () => { var _a; return (_a = config.onFocus) == null ? void 0 : _a.call(config); }); - input.addEventListener("blur", () => { + container.addEventListener("focusout", () => { var _a; - return (_a = config.onBlur) == null ? void 0 : _a.call(config); + commit(); + (_a = config.onBlur) == null ? void 0 : _a.call(config); }); + decrementBtn.addEventListener("mousedown", (e) => e.preventDefault()); + incrementBtn.addEventListener("mousedown", (e) => e.preventDefault()); decrementBtn.addEventListener("click", (e) => { - var _a, _b; e.preventDefault(); e.stopPropagation(); - const step = 1; - const currentVal = parseFloat(input.value); - const baseValue = isNaN(currentVal) ? ((_a = config.constraints) == null ? void 0 : _a.min) ?? Number(config.currentValue) ?? 0 : currentVal; - let newValue = baseValue - step; - if (((_b = config.constraints) == null ? void 0 : _b.min) !== void 0) { - newValue = Math.max(config.constraints.min, newValue); - } - input.value = String(newValue); - syncValue(); + nudge(-1, e); }); incrementBtn.addEventListener("click", (e) => { - var _a, _b; e.preventDefault(); e.stopPropagation(); - const step = 1; - const currentVal = parseFloat(input.value); - const baseValue = isNaN(currentVal) ? ((_a = config.constraints) == null ? void 0 : _a.min) ?? Number(config.currentValue) ?? 0 : currentVal; - let newValue = baseValue + step; - if (((_b = config.constraints) == null ? void 0 : _b.max) !== void 0) { - newValue = Math.min(config.constraints.max, newValue); - } - input.value = String(newValue); - syncValue(); + nudge(1, e); }); input.addEventListener("keydown", (e) => { - if (e.key === "Escape" || e.key === "Enter") { + if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); + nudge(e.key === "ArrowUp" ? 1 : -1, e); + } else if (e.key === "Enter") { + e.preventDefault(); + input.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + input.value = committedValue; input.blur(); } }); @@ -120,20 +112,20 @@ class WidgetEditorFactory { input.select(); }, destroy: () => { + commit(); container.remove(); } }; } /** - * Create a text input editor + * Create a text editor. Commits on blur, Enter (Ctrl/Cmd+Enter in a textarea) and when destroyed; Escape reverts. */ - static createTextEditor(config) { + static createTextEditor(config, multiline) { const value = String(config.currentValue ?? ""); - const isLongText = value.length > 50 || value.includes("\n"); const container = document.createElement("div"); container.className = "widget-editor widget-editor-text"; let inputElement; - if (isLongText) { + if (multiline || value.includes("\n")) { const textarea = document.createElement("textarea"); textarea.className = "widget-editor-textarea"; textarea.value = value; @@ -154,31 +146,34 @@ class WidgetEditorFactory { input.value = value; inputElement = input; } - const syncValue = () => { + inputElement.setAttribute("aria-label", config.widgetName); + const startValue = inputElement.value; + let committedValue = startValue; + const commit = () => { var _a; - WidgetSyncManager.syncWidgetValueDebounced( - config.nodeId, - config.widgetName, - inputElement.value, - 100 - ); - (_a = config.onChange) == null ? void 0 : _a.call(config, inputElement.value); + if (inputElement.value === committedValue) return; + committedValue = inputElement.value; + WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, committedValue); + (_a = config.onChange) == null ? void 0 : _a.call(config, committedValue); }; - inputElement.addEventListener("input", syncValue); inputElement.addEventListener("focus", () => { var _a; return (_a = config.onFocus) == null ? void 0 : _a.call(config); }); inputElement.addEventListener("blur", () => { var _a; - return (_a = config.onBlur) == null ? void 0 : _a.call(config); + commit(); + (_a = config.onBlur) == null ? void 0 : _a.call(config); }); inputElement.addEventListener("keydown", (evt) => { const e = evt; if (e.key === "Escape") { + e.preventDefault(); + inputElement.value = startValue; + commit(); inputElement.blur(); - } - if (e.key === "Enter" && inputElement instanceof HTMLInputElement) { + } else if (e.key === "Enter" && (inputElement instanceof HTMLInputElement || e.ctrlKey || e.metaKey)) { + e.preventDefault(); inputElement.blur(); } }); @@ -194,136 +189,7 @@ class WidgetEditorFactory { if (inputElement instanceof HTMLInputElement) inputElement.select(); }, destroy: () => { - container.remove(); - } - }; - } - /** - * Create a combo/dropdown editor - * Uses custom dropdown for viewport-aware positioning - */ - static createComboEditor(config) { - var _a; - const container = document.createElement("div"); - container.className = "widget-editor widget-editor-combo"; - const options = ((_a = config.constraints) == null ? void 0 : _a.options) ?? []; - let currentValue = String(config.currentValue); - const trigger = createDropdownTrigger(currentValue, "widget-editor-dropdown-trigger"); - trigger.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 4px 28px 4px 10px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 14px; - color: var(--info-panel-accent-color, #a0d468); - background: rgba(160, 212, 104, 0.12); - border: 1px solid transparent; - border-radius: 6px; - cursor: pointer; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; - `; - let activeDropdown = null; - const showDropdown = () => { - var _a2; - if (activeDropdown) { - activeDropdown.hide(); - activeDropdown = null; - return; - } - (_a2 = config.onFocus) == null ? void 0 : _a2.call(config); - activeDropdown = new CustomDropdown({ - options: options.map(String), - currentValue, - anchor: trigger, - onChange: (value) => { - var _a3; - currentValue = value; - updateDropdownTriggerValue(trigger, value); - WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, value); - (_a3 = config.onChange) == null ? void 0 : _a3.call(config, value); - }, - onClose: () => { - var _a3; - activeDropdown = null; - (_a3 = config.onBlur) == null ? void 0 : _a3.call(config); - } - }); - activeDropdown.show(); - }; - trigger.addEventListener("click", showDropdown); - trigger.addEventListener("mouseenter", () => { - trigger.style.background = "rgba(160, 212, 104, 0.18)"; - trigger.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.15)"; - }); - trigger.addEventListener("mouseleave", () => { - trigger.style.background = "rgba(160, 212, 104, 0.12)"; - trigger.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)"; - }); - trigger.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - showDropdown(); - } else if (e.key === "Escape" && activeDropdown) { - activeDropdown.hide(); - } - }); - container.appendChild(trigger); - return { - element: container, - getValue: () => currentValue, - setValue: (v) => { - currentValue = String(v); - updateDropdownTriggerValue(trigger, currentValue); - }, - focus: () => trigger.focus(), - destroy: () => { - activeDropdown == null ? void 0 : activeDropdown.hide(); - container.remove(); - } - }; - } - /** - * Create a boolean toggle editor - */ - static createBooleanEditor(config) { - const container = document.createElement("div"); - container.className = "widget-editor widget-editor-boolean"; - const label = document.createElement("label"); - label.className = "widget-editor-toggle"; - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.className = "widget-editor-checkbox"; - const boolValue = config.currentValue === true || config.currentValue === "true"; - checkbox.checked = boolValue; - const slider = document.createElement("span"); - slider.className = "widget-editor-toggle-slider"; - checkbox.addEventListener("change", () => { - var _a; - WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, checkbox.checked); - (_a = config.onChange) == null ? void 0 : _a.call(config, checkbox.checked); - }); - checkbox.addEventListener("focus", () => { - var _a; - return (_a = config.onFocus) == null ? void 0 : _a.call(config); - }); - checkbox.addEventListener("blur", () => { - var _a; - return (_a = config.onBlur) == null ? void 0 : _a.call(config); - }); - label.appendChild(checkbox); - label.appendChild(slider); - container.appendChild(label); - return { - element: container, - getValue: () => checkbox.checked, - setValue: (v) => { - checkbox.checked = Boolean(v); - }, - focus: () => checkbox.focus(), - destroy: () => { + commit(); container.remove(); } }; @@ -337,11 +203,12 @@ class WidgetEditorFactory { const slider = document.createElement("input"); slider.type = "range"; slider.className = "widget-editor-range"; + slider.setAttribute("aria-label", config.widgetName); slider.value = String(config.currentValue ?? 0); if (config.constraints) { slider.min = String(config.constraints.min ?? 0); slider.max = String(config.constraints.max ?? 100); - slider.step = String(config.constraints.step ?? 0.01); + slider.step = String(WidgetSyncManager.getStep(config.constraints)); } const valueDisplay = document.createElement("span"); valueDisplay.className = "widget-editor-slider-value"; @@ -350,10 +217,11 @@ class WidgetEditorFactory { var _a, _b; const numValue = parseFloat(slider.value); valueDisplay.textContent = numValue.toFixed(((_a = config.constraints) == null ? void 0 : _a.precision) ?? 2); - WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue); + WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, numValue, false); (_b = config.onChange) == null ? void 0 : _b.call(config, numValue); }; slider.addEventListener("input", syncValue); + slider.addEventListener("change", () => WidgetSyncManager.recordUndo()); slider.addEventListener("focus", () => { var _a; return (_a = config.onFocus) == null ? void 0 : _a.call(config); @@ -385,24 +253,6 @@ class WidgetEditorFactory { } }; } - /** - * Check if a widget type is supported for editing - */ - static isTypeSupported(widgetType) { - const supported = [ - "number", - "int", - "float", - "text", - "string", - "customtext", - "combo", - "toggle", - "boolean", - "slider" - ]; - return supported.includes(widgetType.toLowerCase()); - } } export { WidgetEditorFactory diff --git a/web/info-panel/widget-editors/WidgetSyncManager.js b/web/info-panel/widget-editors/WidgetSyncManager.js index fcd3875..08561aa 100644 --- a/web/info-panel/widget-editors/WidgetSyncManager.js +++ b/web/info-panel/widget-editors/WidgetSyncManager.js @@ -1,50 +1,39 @@ -var __defProp = Object.defineProperty; -var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; -var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { Logger } from "../../shared/logger.js"; class WidgetSyncManager { /** - * Update a widget value on a node + * Update a widget value on a node in the graph on screen * @param nodeId - The node ID * @param widgetName - The widget name * @param value - The new value to set + * @param recordUndo - Record an undo step now; drags pass false and record once on release * @returns SyncResult indicating success or failure */ - static syncWidgetValue(nodeId, widgetName, value) { + static syncWidgetValue(nodeId, widgetName, value, recordUndo = true) { + var _a, _b, _c, _d, _e; try { - if (!(app == null ? void 0 : app.graph)) { - return { success: false, error: "ComfyUI app not available" }; - } - const node = app.graph.getNodeById(nodeId); + const node = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(nodeId); if (!node) { return { success: false, error: `Node ${nodeId} not found` }; } - if (!node.widgets || node.widgets.length === 0) { - return { success: false, error: `Node ${nodeId} has no widgets` }; - } - const widget = node.widgets.find((w) => w.name === widgetName); + const widget = (_c = node.widgets) == null ? void 0 : _c.find((w) => w.name === widgetName); if (!widget) { return { success: false, error: `Widget "${widgetName}" not found on node ${nodeId}` }; } const previousValue = widget.value; const constrainedValue = this.applyConstraints(value, widget); - widget.value = constrainedValue; - if (typeof widget.callback === "function") { - try { - widget.callback(constrainedValue, app.canvas, node, [0, 0], null); - } catch (callbackError) { - Logger.warn(`Widget callback failed for ${widgetName}:`, callbackError); - } + if (typeof widget.setValue === "function") { + widget.setValue(constrainedValue, { node, canvas: app.canvas }); + } else { + widget.value = constrainedValue; + (_d = widget.callback) == null ? void 0 : _d.call(widget, constrainedValue, app.canvas, node, app.canvas.graph_mouse); } - if (typeof node.setDirtyCanvas === "function") { - node.setDirtyCanvas(true, true); - } - this.triggerCanvasRedraw(); - Logger.debug(`[WidgetSync] Updated ${widgetName} on node ${nodeId}: ${previousValue} → ${constrainedValue}`); + (_e = node.graph) == null ? void 0 : _e.setDirtyCanvas(true, true); + if (recordUndo) this.recordUndo(); + Logger.debug(`[WidgetSync] Updated ${widgetName} on node ${nodeId}: ${previousValue} → ${widget.value}`); return { success: true, previousValue, - newValue: constrainedValue + newValue: widget.value }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -53,42 +42,11 @@ class WidgetSyncManager { } } /** - * Debounced version of syncWidgetValue for text input - * @param nodeId - The node ID - * @param widgetName - The widget name - * @param value - The new value to set - * @param debounceMs - Debounce delay in milliseconds (default 50ms) + * Let ComfyUI's change tracker pick up the edit as an undo step */ - static syncWidgetValueDebounced(nodeId, widgetName, value, debounceMs = this.DEBOUNCE_MS) { - const key = `${nodeId}:${widgetName}`; - const existingTimer = this.debounceTimers.get(key); - if (existingTimer) { - window.clearTimeout(existingTimer); - } - const timer = window.setTimeout(() => { - this.syncWidgetValue(nodeId, widgetName, value); - this.debounceTimers.delete(key); - }, debounceMs); - this.debounceTimers.set(key, timer); - } - /** - * Get widget constraints for validation - * @param nodeId - The node ID - * @param widgetName - The widget name - * @returns WidgetConstraints or null if widget not found - */ - static getWidgetConstraints(nodeId, widgetName) { - try { - if (!(app == null ? void 0 : app.graph)) return null; - const node = app.graph.getNodeById(nodeId); - if (!(node == null ? void 0 : node.widgets)) return null; - const widget = node.widgets.find((w) => w.name === widgetName); - if (!widget) return null; - return this.extractConstraints(widget); - } catch (error) { - Logger.warn(`[WidgetSync] Error getting constraints:`, error); - return null; - } + static recordUndo() { + var _a, _b, _c, _d; + (_d = (_c = (_b = (_a = app == null ? void 0 : app.extensionManager) == null ? void 0 : _a.workflow) == null ? void 0 : _b.activeWorkflow) == null ? void 0 : _c.changeTracker) == null ? void 0 : _d.checkState(); } /** * Extract constraints from a widget object @@ -98,40 +56,56 @@ class WidgetSyncManager { if (typeof widget.min === "number") constraints.min = widget.min; if (typeof widget.max === "number") constraints.max = widget.max; if (typeof widget.step === "number") constraints.step = widget.step; - if (widget.options) { - if (typeof widget.options.min === "number") constraints.min = widget.options.min; - if (typeof widget.options.max === "number") constraints.max = widget.options.max; - if (typeof widget.options.step === "number") constraints.step = widget.options.step; - if (typeof widget.options.precision === "number") constraints.precision = widget.options.precision; - if (Array.isArray(widget.options.values)) { - constraints.options = widget.options.values; + const options = widget.options; + if (options && !Array.isArray(options)) { + if (typeof options.min === "number") constraints.min = options.min; + if (typeof options.max === "number") constraints.max = options.max; + if (typeof options.step2 === "number") constraints.step = options.step2; + else if (typeof options.step === "number") constraints.step = options.step / 10; + if (typeof options.round === "number" && options.round > 0) constraints.round = options.round; + if (typeof options.precision === "number") constraints.precision = options.precision; + const values = typeof options.values === "function" ? options.values(widget, widget.node) : options.values; + if (Array.isArray(values)) { + constraints.options = values; } } - if (Array.isArray(widget.options)) { - constraints.options = widget.options; + if (Array.isArray(options)) { + constraints.options = options; } return constraints; } + /** + * Step for one nudge of a number widget. Shift is x10, Alt is x0.1 but never finer than the widget stores. + */ + static getStep(constraints, e) { + const step = (constraints == null ? void 0 : constraints.step) ?? 1; + if (e == null ? void 0 : e.shiftKey) return step * 10; + if (e == null ? void 0 : e.altKey) return Math.max(step / 10, (constraints == null ? void 0 : constraints.round) ?? 10 ** -((constraints == null ? void 0 : constraints.precision) ?? 3)); + return step; + } /** * Apply constraints to a value */ static applyConstraints(value, widget) { const constraints = this.extractConstraints(widget); const widgetType = this.getWidgetType(widget); - if (widgetType === "number" || widgetType === "INT" || widgetType === "FLOAT" || widgetType === "slider") { + if (widgetType === "INT" || widgetType === "FLOAT" || widgetType === "slider") { let numValue = typeof value === "number" ? value : parseFloat(String(value)); if (isNaN(numValue)) { return widget.value; } + if (constraints.round) { + numValue = Math.round(numValue / constraints.round) * constraints.round; + } + if (typeof constraints.precision === "number") { + numValue = parseFloat(numValue.toFixed(constraints.precision)); + } if (typeof constraints.min === "number") { numValue = Math.max(constraints.min, numValue); } if (typeof constraints.max === "number") { numValue = Math.min(constraints.max, numValue); } - if (typeof constraints.precision === "number") { - numValue = parseFloat(numValue.toFixed(constraints.precision)); - } return numValue; } if (widgetType === "combo" && constraints.options) { @@ -155,14 +129,16 @@ class WidgetSyncManager { * Determine widget type from widget object */ static getWidgetType(widget) { + var _a, _b; if (!widget) return "unknown"; const type = (widget.type || "").toLowerCase(); if (type === "button") return "button"; - if (type === "number" || type === "int" || type === "float") return "number"; + if (type === "number" || type === "knob") return ((_a = widget.options) == null ? void 0 : _a.precision) === 0 ? "INT" : "FLOAT"; if (type === "combo" || type === "string" && Array.isArray(widget.options)) return "combo"; if (type === "toggle" || type === "boolean") return "boolean"; if (type === "slider") return "slider"; - if (type === "text" || type === "string" || type === "customtext") return "text"; + if (type === "customtext" || ((_b = widget.options) == null ? void 0 : _b.multiline)) return "multiline"; + if (type === "text" || type === "string") return "text"; if (Array.isArray(widget.options) && widget.options.length > 0) { return "combo"; } @@ -187,70 +163,20 @@ class WidgetSyncManager { if (!widget) return false; if (widget.hidden) return false; if (widget.readonly) return false; + if (widget.value !== null && typeof widget.value === "object") return false; const type = this.getWidgetType(widget); - if (type === "button") return false; - const editableTypes = ["number", "text", "combo", "boolean", "slider", "INT", "FLOAT"]; - return editableTypes.includes(type) || editableTypes.includes(type.toUpperCase()); - } - /** - * Force canvas redraw after widget update - */ - static triggerCanvasRedraw() { - try { - if (app == null ? void 0 : app.canvas) { - if (typeof app.canvas.setDirty === "function") { - app.canvas.setDirty(true, true); - } - if (typeof app.canvas.draw === "function") { - app.canvas.draw(true, true); - } - } - } catch (error) { - Logger.warn(`[WidgetSync] Canvas redraw failed:`, error); - } + if (widget.element && type !== "multiline") return false; + return ["INT", "FLOAT", "text", "multiline", "combo", "boolean", "slider"].includes(type); } /** * Get current value of a widget */ static getWidgetValue(nodeId, widgetName) { - try { - if (!(app == null ? void 0 : app.graph)) return null; - const node = app.graph.getNodeById(nodeId); - if (!(node == null ? void 0 : node.widgets)) return null; - const widget = node.widgets.find((w) => w.name === widgetName); - return (widget == null ? void 0 : widget.value) ?? null; - } catch (error) { - return null; - } - } - /** - * Validate a value against widget constraints without applying - */ - static validateValue(nodeId, widgetName, value) { - const constraints = this.getWidgetConstraints(nodeId, widgetName); - if (!constraints) { - return { valid: true }; - } - if (typeof value === "number") { - if (typeof constraints.min === "number" && value < constraints.min) { - return { valid: false, error: `Value must be at least ${constraints.min}` }; - } - if (typeof constraints.max === "number" && value > constraints.max) { - return { valid: false, error: `Value must be at most ${constraints.max}` }; - } - } - if (constraints.options) { - const valueStr = String(value); - const hasMatch = constraints.options.some((opt) => String(opt) === valueStr); - if (!hasMatch) { - return { valid: false, error: `Invalid option selected` }; - } - } - return { valid: true }; + var _a, _b, _c, _d; + const widget = (_d = (_c = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(nodeId)) == null ? void 0 : _c.widgets) == null ? void 0 : _d.find((w) => w.name === widgetName); + return (widget == null ? void 0 : widget.value) ?? null; } } -__publicField(WidgetSyncManager, "debounceTimers", /* @__PURE__ */ new Map()); -__publicField(WidgetSyncManager, "DEBOUNCE_MS", 50); export { WidgetSyncManager }; diff --git a/web/magnify-glass/EventHandler.js b/web/magnify-glass/EventHandler.js index 679a17f..db78279 100644 --- a/web/magnify-glass/EventHandler.js +++ b/web/magnify-glass/EventHandler.js @@ -79,11 +79,11 @@ class EventHandler { config.offsetY += stepSize; offsetChanged = true; e.preventDefault(); - } else if (e.key === "ArrowLeft") { + } else if (e.key === "ArrowLeft" && !e.defaultPrevented) { config.offsetX -= stepSize; offsetChanged = true; e.preventDefault(); - } else if (e.key === "ArrowRight") { + } else if (e.key === "ArrowRight" && !e.defaultPrevented) { config.offsetX += stepSize; offsetChanged = true; e.preventDefault(); diff --git a/web/magnify-glass/MagnifyGlass.js b/web/magnify-glass/MagnifyGlass.js index 9a7d7aa..80b82b0 100644 --- a/web/magnify-glass/MagnifyGlass.js +++ b/web/magnify-glass/MagnifyGlass.js @@ -2,7 +2,8 @@ var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { findLiteGraphCanvas } from "../shared/utils.js"; -import { INFO_PANEL_ID, DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from "../shared/constants.js"; +import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from "../shared/constants.js"; +import "../shared/settings/defaults.js"; import { registerGlassSettings } from "../shared/settings/glassSettings.js"; import "/scripts/app.js"; import { registerAccessibilitySettings } from "../shared/settings/accessibilitySettings.js"; @@ -29,10 +30,6 @@ class MagnifyGlass { __publicField(this, "litegraphCanvas"); /** Last known mouse position for better initial positioning */ __publicField(this, "lastKnownMousePosition"); - /** Whether we're currently over media (for info panel) */ - __publicField(this, "isOverMedia"); - /** Current media element under cursor */ - __publicField(this, "currentMediaElement"); /** Render loop ID; the loop runs while the glass is active and visible */ __publicField(this, "animationLoopId"); this.config = new ConfigManager(); @@ -49,8 +46,6 @@ class MagnifyGlass { this.eventHandler = new EventHandler(this); this.litegraphCanvas = null; this.lastKnownMousePosition = { x: 0, y: 0 }; - this.isOverMedia = false; - this.currentMediaElement = null; this.animationLoopId = null; } /** @@ -102,25 +97,17 @@ class MagnifyGlass { } /** * Force hide all components including extensions (Info Panel, etc). - * This overcomes the "pinned" state of the info panel when the main tool is deactivated. + * This hides the info panel even while it floats, when the main tool is deactivated. */ forceHideAllComponents() { this.state.active = false; this.stopAnimationLoop(); this.ui.hide(); - const panelEl = document.getElementById(INFO_PANEL_ID); - if (panelEl) { - panelEl.style.display = "none"; - panelEl.style.opacity = "0"; - } const extensions = window.comfyUIMagnifyGlassExtensions; if (extensions && extensions.length > 0) { extensions.forEach((extension) => { if (extension && extension.uiManager && typeof extension.uiManager.hide === "function") { extension.uiManager.hide(); - if (extension.uiManager.elements && extension.uiManager.elements.panel) { - extension.uiManager.elements.panel.style.display = "none"; - } } }); } @@ -239,6 +226,7 @@ class MagnifyGlass { * Reset offsets and panel positions to defaults. */ resetOffsets() { + var _a; this.config.resetOffsets(); this.config.followCursor = false; if (this.state.active) { @@ -258,25 +246,7 @@ class MagnifyGlass { this.ui.glassDiv.style.left = "auto"; } } - const extensions = window.comfyUIMagnifyGlassExtensions; - if (extensions && extensions.length > 0) { - extensions.forEach((extension) => { - var _a; - if (extension == null ? void 0 : extension.stateManager) { - extension.stateManager.state.isPanelPinned = false; - extension.stateManager.state.isPanelLocked = false; - extension.stateManager.state.isAutoPinned = false; - extension.stateManager.state.pinnedPosition = { x: 0, y: 0 }; - extension.stateManager.state.lastPinnedPosition = null; - if (extension.positionManager) { - extension.positionManager.positionPanel(); - } - if ((_a = extension.uiManager) == null ? void 0 : _a.updateControlStates) { - extension.uiManager.updateControlStates(); - } - } - }); - } + (_a = window.comfyUIMagnifyGlassExtensions) == null ? void 0 : _a.forEach((extension) => extension.resetPosition()); this.debugger.log("Reset: Magnify glass and inspector panel positions restored to defaults"); } /** diff --git a/web/magnify-glass/PopOutManager.js b/web/magnify-glass/PopOutManager.js index de747f4..8fbc03c 100644 --- a/web/magnify-glass/PopOutManager.js +++ b/web/magnify-glass/PopOutManager.js @@ -15,8 +15,8 @@ class PopOutManager { __publicField(this, "lastPongTime", 0); __publicField(this, "pingInterval", null); __publicField(this, "viewerUrl"); - __publicField(this, "currentTheme", "dark"); - // Default theme + // The theme last sent to the viewer, null until it connects + __publicField(this, "sentTheme", null); /** Throttle frame sending to ~30fps */ __publicField(this, "lastFrameTime", 0); __publicField(this, "FRAME_INTERVAL", 33); @@ -74,16 +74,20 @@ class PopOutManager { * Handle incoming messages from the viewer tab. */ handleMessage(message) { + var _a, _b; switch (message.type) { - case "pong": + case "pong": { this.lastPongTime = Date.now(); if (!this.isOpen) { this.isOpen = true; + this.sentTheme = null; Logger.debug("[PopOut] Viewer tab connected"); if (this.onStateChange) this.onStateChange(true); - this.sendConfig({ theme: this.currentTheme }); } + const theme = document.body.classList.contains("dark-theme") ? "dark" : "light"; + if (theme !== this.sentTheme) this.sendConfig({ theme }); break; + } case "close": this.isOpen = false; this.popOutWindow = null; @@ -102,8 +106,8 @@ class PopOutManager { case "zoom-node": if (typeof message.data === "number") { const app = window.app; - const node = app.graph.getNodeById(message.data); - if (node && app.canvas) { + const node = (_b = (_a = app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(message.data); + if (node) { app.canvas.centerOnNode(node); } } @@ -144,16 +148,13 @@ class PopOutManager { * Invokes the widget's callback function. */ handleButtonClick(buttonData) { + var _a, _b; if (!buttonData || typeof buttonData.nodeId !== "number" || !buttonData.widgetName) { Logger.warn("[PopOut] Invalid button click data:", buttonData); return; } const app = window.app; - if (!(app == null ? void 0 : app.graph)) { - Logger.warn("[PopOut] No app.graph available for button click"); - return; - } - const node = app.graph.getNodeById(buttonData.nodeId); + const node = (_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b.getNodeById(buttonData.nodeId); if (!(node == null ? void 0 : node.widgets)) { Logger.warn(`[PopOut] Node ${buttonData.nodeId} not found or has no widgets`); return; @@ -240,36 +241,19 @@ class PopOutManager { Logger.error("[PopOut] Failed to send frame:", e); } } - /** - * Send configuration to the pop-out viewer. - */ /** * Send configuration to the pop-out viewer. */ sendConfig(config) { if (!this.channel) return; - const finalConfig = { - theme: this.currentTheme, - ...config - }; if (config.theme) { - this.currentTheme = config.theme; + this.sentTheme = config.theme; } this.sendMessage({ type: "config", - data: finalConfig + data: config }); } - /** - * Update the viewer theme. - * @param theme - Theme name (e.g. 'dark', 'light') - */ - updateTheme(theme) { - if (this.currentTheme === theme) return; - this.currentTheme = theme; - this.sendConfig({ theme }); - Logger.debug(`[PopOut] Theme updated to: ${theme}`); - } /** * Send inspector info to the pop-out viewer. * @param info - Inspector panel information @@ -287,11 +271,11 @@ class PopOutManager { * Fetches nodes from canvas and sends them to the popout. */ handleNodeListRequest(sortBy) { - var _a; + var _a, _b; if (!this.channel) return; try { const app = window.app; - const nodes = ((_a = app == null ? void 0 : app.graph) == null ? void 0 : _a._nodes) || []; + const nodes = ((_b = (_a = app == null ? void 0 : app.canvas) == null ? void 0 : _a.graph) == null ? void 0 : _b._nodes) || []; let nodeList; if (sortBy === "execOrder") { nodeList = nodes.map((n) => ({ @@ -369,13 +353,6 @@ class PopOutManager { zoomFactor: Number(magnifyGlass.config.zoomFactor || 1) }; } - if (info.mediaElement || info.media) { - const media = info.mediaElement || info.media; - sanitized.media = { - tagName: String(media.tagName || media.type || ""), - naturalSize: media.naturalWidth && media.naturalHeight ? `${media.naturalWidth}×${media.naturalHeight}` : media.naturalSize - }; - } return sanitized; } catch (e) { Logger.error("[PopOut] Failed to sanitize info:", e); diff --git a/web/magnify_glass.js b/web/magnify_glass.js index 5fbc5ce..d923646 100644 --- a/web/magnify_glass.js +++ b/web/magnify_glass.js @@ -2,6 +2,7 @@ import { MagnifyGlass } from "./magnify-glass/MagnifyGlass.js"; import { app } from "/scripts/app.js"; import { Logger } from "./shared/logger.js"; import { initSidebar } from "./sidebar/sidebar.js"; +import "./shared/settings/defaults.js"; import { ABOUT_BADGES } from "./sidebar/SidebarAbout.js"; app.registerExtension({ name: "comfyui.magnify.glass", diff --git a/web/magnify_info_panel.js b/web/magnify_info_panel.js index 8a1ecfc..f8c9ba2 100644 --- a/web/magnify_info_panel.js +++ b/web/magnify_info_panel.js @@ -1,4 +1,5 @@ import { app } from "/scripts/app.js"; +import "./shared/settings/defaults.js"; import { registerPanelSettings } from "./shared/settings/panelSettings.js"; import { mountInfoPanel, unmountInfoPanel } from "./info-panel/InfoPanel.js"; app.registerExtension({ @@ -12,6 +13,11 @@ app.registerExtension({ } }; checkDependencies(); + }, + // Undo, redo and loading a workflow replace the nodes without moving the mouse, which drives the other updates + afterConfigureGraph() { + var _a, _b; + if ((_a = window.comfyUIMagnifyGlass) == null ? void 0 : _a.state.active) (_b = window.infoPanelManager) == null ? void 0 : _b.scheduleInfoUpdate(); } }); function initializeInfoPanel(magnifyGlass) { diff --git a/web/popout-viewer.html b/web/popout-viewer.html index c25f028..dd17158 100644 --- a/web/popout-viewer.html +++ b/web/popout-viewer.html @@ -5,11 +5,6 @@ 🔍 MagnifyGlass Viewer - - -