feat: add new themes and enhance popout viewer interaction

- Added 'Obsidian', 'Obsidian Dark', and 'Milk White' themes to Info Panel and PopOut viewer.
- Improved theme detection in 'StateManager' to support new themes.
- Implemented button widget support in PopOut viewer with 'button-click' event handling.
- Enhanced widget options handling in 'PopOutManager' for combo widgets.
- Updated dropdown accessibility tests.
This commit is contained in:
AEmotionStudio
2026-01-18 11:02:06 -08:00
parent cb589761c8
commit 8bdfbedf46
10 changed files with 597 additions and 89 deletions
+99 -6
View File
@@ -59,7 +59,7 @@
}
.magnify-info-panel.theme-dark {
background: linear-gradient(135deg, rgba(0, 0, 0, 0.96), rgba(10, 10, 15, 0.96));
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);
@@ -67,7 +67,7 @@
}
.magnify-info-panel.theme-light {
background: linear-gradient(135deg, rgba(250, 250, 255, 0.96), rgba(240, 240, 250, 0.96));
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);
@@ -76,7 +76,7 @@
/* Solarized Theme - warm, muted tones */
.magnify-info-panel.theme-solarized {
background: linear-gradient(135deg, rgba(0, 43, 54, 0.96), rgba(7, 54, 66, 0.96));
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);
@@ -86,7 +86,7 @@
/* Arc Theme - modern blue-gray */
.magnify-info-panel.theme-arc {
background: linear-gradient(135deg, rgba(56, 60, 74, 0.96), rgba(64, 69, 82, 0.96));
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);
@@ -96,7 +96,7 @@
/* Nord Theme - cool arctic colors */
.magnify-info-panel.theme-nord {
background: linear-gradient(135deg, rgba(46, 52, 64, 0.96), rgba(59, 66, 82, 0.96));
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);
@@ -106,7 +106,7 @@
/* GitHub Theme - clean, professional */
.magnify-info-panel.theme-github {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(246, 248, 250, 0.96));
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);
@@ -114,6 +114,37 @@
--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;
}
@@ -1265,6 +1296,68 @@
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
========================================================================== */
+14 -2
View File
@@ -148,7 +148,13 @@ export class StateManager {
// Extract theme name from various possible formats
const paletteStr = String(colorPalette).toLowerCase();
if (paletteStr.includes('solarized')) {
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';
@@ -180,7 +186,13 @@ export class StateManager {
// Check for theme classes
const classes = (body.className + ' ' + html.className).toLowerCase();
if (classes.includes('theme-solarized') || classes.includes('solarized')) {
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';
+58 -4
View File
@@ -13,7 +13,7 @@ import { WidgetSyncManager } from '../info-panel/widget-editors/WidgetSyncManage
* Message types for BroadcastChannel communication
*/
interface PopOutMessage {
type: 'frame' | 'config' | 'info' | 'close' | 'ping' | 'pong' | 'node-select' | 'nodes-list' | 'request-nodes' | 'zoom-node' | 'widget-edit';
type: 'frame' | 'config' | 'info' | 'close' | 'ping' | 'pong' | 'node-select' | 'nodes-list' | 'request-nodes' | 'zoom-node' | 'widget-edit' | 'button-click';
data?: string | Partial<PopOutConfig> | PopOutInfo | NodeListData | number | WidgetEditData;
timestamp?: number;
}
@@ -212,6 +212,10 @@ export class PopOutManager {
// Popout is sending a widget value edit
this.handleWidgetEdit(message.data as WidgetEditData);
break;
case 'button-click':
// Popout is requesting a button widget callback invocation
this.handleButtonClick(message.data as { nodeId: number; widgetName: string });
break;
}
}
@@ -238,6 +242,41 @@ export class PopOutManager {
}
}
/**
* Handle button click command from pop-out viewer.
* Invokes the widget's callback function.
*/
private handleButtonClick(buttonData: { nodeId: number; widgetName: string }): void {
if (!buttonData || typeof buttonData.nodeId !== 'number' || !buttonData.widgetName) {
Logger.warn('[PopOut] Invalid button click data:', buttonData);
return;
}
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);
if (!node?.widgets) {
Logger.warn(`[PopOut] Node ${buttonData.nodeId} not found or has no widgets`);
return;
}
const widget = node.widgets.find((w: any) => w.name === buttonData.widgetName);
if (widget && typeof widget.callback === 'function') {
try {
widget.callback(widget.value, app.canvas, node, [0, 0], null);
Logger.debug(`[PopOut] Button ${buttonData.widgetName} clicked successfully`);
} catch (error) {
Logger.warn(`[PopOut] Button callback failed:`, error);
}
} else {
Logger.warn(`[PopOut] Widget ${buttonData.widgetName} not found or has no callback`);
}
}
/**
* Open the pop-out viewer in a new tab.
*/
@@ -536,9 +575,24 @@ export class PopOutManager {
safeItem.value = item.value;
}
// Sanitized options for widgets
if (item.options) {
safeItem.options = this.sanitizeOptions(item.options);
// Handle widget options - preserve arrays for combo dropdowns
if (item.options !== undefined) {
if (Array.isArray(item.options)) {
// For combo widgets, options is an array of values - keep it as is
safeItem.options = item.options.filter(
(opt: any) => typeof opt !== 'function'
);
} else if (typeof item.options === 'object' && item.options !== null) {
// Check for options.values (ComfyUI nested format for combo widgets)
if (Array.isArray(item.options.values)) {
safeItem.options = item.options.values.filter(
(opt: any) => typeof opt !== 'function'
);
} else {
// For other widgets, options is an object with config (min, max, step, etc)
safeItem.options = this.sanitizeOptions(item.options);
}
}
}
return safeItem;
+21 -25
View File
@@ -82,7 +82,8 @@ describe('UIManager Dropdown Accessibility', () => {
it('should cleanup event listeners when item is selected', () => {
vi.useFakeTimers();
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const docRemoveSpy = vi.spyOn(document, 'removeEventListener');
const winRemoveSpy = vi.spyOn(window, 'removeEventListener');
const nodes = [{ id: 1, title: 'Node 1', type: 'Type A' }];
const anchor = document.createElement('div');
@@ -98,14 +99,15 @@ describe('UIManager Dropdown Accessibility', () => {
// Simulate click
item.click();
// Assert cleanup was called
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true);
// Assert cleanup was called - keydown is on window, mousedown on document
expect(winRemoveSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
expect(docRemoveSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true);
vi.useRealTimers();
});
it('should ignore keyboard events when dropdown is not focused', () => {
it('should focus dropdown on creation for keyboard accessibility', () => {
// Test that dropdown receives focus when created, enabling keyboard navigation
vi.useFakeTimers();
const nodes = [{ id: 1, title: 'Node 1', type: 'Type A' }, { id: 2, title: 'Node 2', type: 'Type B' }];
@@ -117,29 +119,22 @@ describe('UIManager Dropdown Accessibility', () => {
vi.advanceTimersByTime(100);
const dropdown = document.querySelector('.node-selector-dropdown') as HTMLElement;
// Dropdown should have focus for keyboard accessibility
expect(document.activeElement).toBe(dropdown);
// First item should be selected by default
const items = dropdown.querySelectorAll('.dropdown-item');
// Simulate focus moving elsewhere (e.g., user tabs away)
const otherInput = document.createElement('input');
document.body.appendChild(otherInput);
otherInput.focus();
expect(document.activeElement).toBe(otherInput);
// Arrow Down - should NOT change selection if focus is elsewhere
const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
document.dispatchEvent(event);
expect(preventDefaultSpy).not.toHaveBeenCalled();
expect(items[1].getAttribute('aria-selected')).toBe('false'); // Should stay on first item
expect(items[0].getAttribute('aria-selected')).toBe('true');
expect(items[1].getAttribute('aria-selected')).toBe('false');
vi.useRealTimers();
});
it('should cleanup previous event listeners when opening a new dropdown via anchor click', () => {
vi.useFakeTimers();
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const docRemoveSpy = vi.spyOn(document, 'removeEventListener');
const winRemoveSpy = vi.spyOn(window, 'removeEventListener');
const nodes = [{ id: 1, title: 'Node 1', type: 'Type A' }];
const anchor = document.createElement('div');
@@ -149,15 +144,16 @@ describe('UIManager Dropdown Accessibility', () => {
(uiManager as any).createDropdown(nodes, anchor, 'title');
vi.advanceTimersByTime(100);
// Reset spy to track calls for the next action
removeEventListenerSpy.mockClear();
// Reset spies to track calls for the next action
docRemoveSpy.mockClear();
winRemoveSpy.mockClear();
// Simulate re-opening (which calls hideDropdown then createDropdown)
(uiManager as any).showTitleDropdown(anchor);
// Verify that cleanup for the FIRST dropdown occurred
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true);
expect(winRemoveSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
expect(docRemoveSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true);
vi.useRealTimers();
});
+99 -6
View File
@@ -59,7 +59,7 @@
}
.magnify-info-panel.theme-dark {
background: linear-gradient(135deg, rgba(0, 0, 0, 0.96), rgba(10, 10, 15, 0.96));
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);
@@ -67,7 +67,7 @@
}
.magnify-info-panel.theme-light {
background: linear-gradient(135deg, rgba(250, 250, 255, 0.96), rgba(240, 240, 250, 0.96));
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);
@@ -76,7 +76,7 @@
/* Solarized Theme - warm, muted tones */
.magnify-info-panel.theme-solarized {
background: linear-gradient(135deg, rgba(0, 43, 54, 0.96), rgba(7, 54, 66, 0.96));
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);
@@ -86,7 +86,7 @@
/* Arc Theme - modern blue-gray */
.magnify-info-panel.theme-arc {
background: linear-gradient(135deg, rgba(56, 60, 74, 0.96), rgba(64, 69, 82, 0.96));
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);
@@ -96,7 +96,7 @@
/* Nord Theme - cool arctic colors */
.magnify-info-panel.theme-nord {
background: linear-gradient(135deg, rgba(46, 52, 64, 0.96), rgba(59, 66, 82, 0.96));
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);
@@ -106,7 +106,7 @@
/* GitHub Theme - clean, professional */
.magnify-info-panel.theme-github {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(246, 248, 250, 0.96));
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);
@@ -114,6 +114,37 @@
--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;
}
@@ -1265,6 +1296,68 @@
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
========================================================================== */
+14 -2
View File
@@ -71,7 +71,13 @@ class StateManager {
const colorPalette = getSettingValue("Comfy.ColorPalette", "");
if (colorPalette) {
const paletteStr = String(colorPalette).toLowerCase();
if (paletteStr.includes("solarized")) {
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";
@@ -96,7 +102,13 @@ class StateManager {
detectedTheme = dataTheme.toLowerCase();
}
const classes = (body.className + " " + html.className).toLowerCase();
if (classes.includes("theme-solarized") || classes.includes("solarized")) {
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";
File diff suppressed because one or more lines are too long
+48 -2
View File
@@ -110,6 +110,9 @@ class PopOutManager {
case "widget-edit":
this.handleWidgetEdit(message.data);
break;
case "button-click":
this.handleButtonClick(message.data);
break;
}
}
/**
@@ -132,6 +135,37 @@ class PopOutManager {
Logger.warn(`[PopOut] Widget edit failed: ${result.error}`);
}
}
/**
* Handle button click command from pop-out viewer.
* Invokes the widget's callback function.
*/
handleButtonClick(buttonData) {
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);
if (!(node == null ? void 0 : node.widgets)) {
Logger.warn(`[PopOut] Node ${buttonData.nodeId} not found or has no widgets`);
return;
}
const widget = node.widgets.find((w) => w.name === buttonData.widgetName);
if (widget && typeof widget.callback === "function") {
try {
widget.callback(widget.value, app.canvas, node, [0, 0], null);
Logger.debug(`[PopOut] Button ${buttonData.widgetName} clicked successfully`);
} catch (error) {
Logger.warn(`[PopOut] Button callback failed:`, error);
}
} else {
Logger.warn(`[PopOut] Widget ${buttonData.widgetName} not found or has no callback`);
}
}
/**
* Open the pop-out viewer in a new tab.
*/
@@ -360,8 +394,20 @@ class PopOutManager {
if (item.value !== void 0 && typeof item.value !== "function") {
safeItem.value = item.value;
}
if (item.options) {
safeItem.options = this.sanitizeOptions(item.options);
if (item.options !== void 0) {
if (Array.isArray(item.options)) {
safeItem.options = item.options.filter(
(opt) => typeof opt !== "function"
);
} else if (typeof item.options === "object" && item.options !== null) {
if (Array.isArray(item.options.values)) {
safeItem.options = item.options.values.filter(
(opt) => typeof opt !== "function"
);
} else {
safeItem.options = this.sanitizeOptions(item.options);
}
}
}
return safeItem;
});
File diff suppressed because one or more lines are too long
+242 -40
View File
@@ -157,6 +157,54 @@
--accent-green: #a6e22e;
}
/* Obsidian Theme - deep purple/charcoal */
[data-theme="obsidian"] {
--bg-primary: rgb(30, 30, 40);
--bg-secondary: rgb(40, 40, 55);
--bg-tertiary: rgb(50, 50, 65);
--bg-elevated: rgb(45, 45, 60);
--border-subtle: rgba(80, 80, 100, 0.3);
--border-default: rgba(80, 80, 100, 0.5);
--text-primary: #d4d4d8;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--accent-blue: #8b5cf6;
--accent-green: #a78bfa;
--accent-purple: #8b5cf6;
}
/* Obsidian Dark Theme - even darker obsidian */
[data-theme="obsidian-dark"] {
--bg-primary: rgb(15, 15, 20);
--bg-secondary: rgb(25, 25, 35);
--bg-tertiary: rgb(35, 35, 45);
--bg-elevated: rgb(30, 30, 40);
--border-subtle: rgba(60, 60, 80, 0.3);
--border-default: rgba(60, 60, 80, 0.5);
--text-primary: #c4c4cc;
--text-secondary: #9494a0;
--text-tertiary: #64647a;
--accent-blue: #a78bfa;
--accent-green: #c4b5fd;
--accent-purple: #a78bfa;
}
/* Milk White Theme - soft cream/white */
[data-theme="milk-white"] {
--bg-primary: rgb(255, 253, 250);
--bg-secondary: rgb(250, 248, 245);
--bg-tertiary: rgb(245, 243, 240);
--bg-elevated: rgb(255, 255, 255);
--border-subtle: rgba(220, 215, 210, 0.5);
--border-default: rgba(220, 215, 210, 0.8);
--text-primary: #3d3d3d;
--text-secondary: #5a5a5a;
--text-tertiary: #888888;
--accent-blue: #7c3aed;
--accent-green: #059669;
--accent-purple: #7c3aed;
}
* {
margin: 0;
padding: 0;
@@ -803,14 +851,17 @@
border: 1px solid transparent;
transition: border-color 0.15s ease;
}
.widget-value-editable:hover {
border-color: var(--accent-blue);
}
.widget-editor-inline {
display: flex;
align-items: center;
gap: 4px;
}
.widget-editor-input {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
@@ -822,9 +873,11 @@
width: 100%;
outline: none;
}
.widget-editor-input:focus {
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.25);
}
.widget-editor-select {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
@@ -1086,61 +1139,81 @@
});
}
/**
* Send button click to main window via BroadcastChannel
* This invokes the widget's callback function on the main canvas
*/
function sendButtonClick(nodeId, widgetName) {
channel.postMessage({
type: 'button-click',
data: {
nodeId: nodeId,
widgetName: widgetName
},
timestamp: Date.now()
});
}
/**
* Start editing a widget value inline
*/
function editWidgetValue(element, nodeId, widgetName, currentValue, widgetType, options) {
// Already editing?
if (element.querySelector('.widget-editor-inline')) return;
const originalHtml = element.innerHTML;
const isCombo = widgetType === 'combo' && Array.isArray(options) && options.length > 0;
// Check for combo by looking at options array, not just type
const isCombo = Array.isArray(options) && options.length > 0;
const isNumber = widgetType === 'number' || widgetType === 'int' || widgetType === 'float';
const isBoolean = widgetType === 'toggle' || widgetType === 'boolean';
let editorHtml;
if (isCombo) {
const optionsHtml = options.map(opt =>
const optionsHtml = options.map(opt =>
`<option value="${escapeHtml(String(opt))}" ${opt == currentValue ? 'selected' : ''}>${escapeHtml(String(opt))}</option>`
).join('');
editorHtml = `<select class="widget-editor-select" onchange="commitWidgetEdit(this, ${nodeId}, '${widgetName}')">${optionsHtml}</select>`;
} else if (isBoolean) {
// Toggle immediately for boolean
const newValue = currentValue !== true && currentValue !== 'true';
// Read the CURRENT displayed value from the element text, not the stale parameter
const displayedText = element.textContent?.trim().toLowerCase();
const currentBool = displayedText === 'true';
const newValue = !currentBool;
sendWidgetEdit(nodeId, widgetName, newValue);
element.innerHTML = `<span style="color: ${newValue ? 'var(--accent-green)' : 'var(--text-tertiary)'}">${newValue ? 'true' : 'false'}</span>`;
// Update display - keep it simple, just update text and color
element.textContent = newValue ? 'true' : 'false';
element.style.color = newValue ? 'var(--accent-green)' : 'var(--text-tertiary)';
return;
} else {
const inputType = isNumber ? 'number' : 'text';
editorHtml = `<input type="${inputType}" class="widget-editor-input" value="${escapeHtml(String(currentValue))}"
onblur="commitWidgetEdit(this, ${nodeId}, '${widgetName}')"
onkeydown="if(event.key==='Enter'){this.blur();}if(event.key==='Escape'){cancelWidgetEdit(this,'${escapeHtml(originalHtml.replace(/'/g, "\\\\'" ))}');}"/>`;
onkeydown="if(event.key==='Enter'){this.blur();}if(event.key==='Escape'){cancelWidgetEdit(this,'${escapeHtml(originalHtml.replace(/'/g, "\\\\'"))}');}"/>`;
}
element.innerHTML = `<div class="widget-editor-inline">${editorHtml}</div>`;
const input = element.querySelector('input, select');
if (input) {
input.focus();
if (input.select) input.select();
}
}
/**
* Commit widget edit and send to main window
*/
function commitWidgetEdit(input, nodeId, widgetName) {
const value = input.type === 'number' ? parseFloat(input.value) : input.value;
sendWidgetEdit(nodeId, widgetName, value);
// Update display
const container = input.closest('.widget-value-editable');
if (container) {
container.innerHTML = `<span style="color: var(--accent-blue);">${escapeHtml(String(value))}</span>`;
}
}
/**
* Cancel widget edit
*/
@@ -1248,7 +1321,7 @@
<!-- Dynamic Lists (Inputs/Outputs/Widgets) -->
${renderList('Inputs', info.hoveredNode.inputs)}
${renderList('Outputs', info.hoveredNode.outputs)}
${renderList('Widgets', info.hoveredNode.widgets)}
${renderList('Widgets', info.hoveredNode.widgets, info.hoveredNode.id)}
</div>
</div>
`;
@@ -1317,7 +1390,7 @@
return String(pos);
}
function renderList(title, items) {
function renderList(title, items, nodeId = null) {
if (!items || items.length === 0) return '';
// Icon mapping for different list types
@@ -1344,16 +1417,150 @@
};
const badgeColor = badgeColors[title] || '#888';
// Check if this is the Widgets section and we have a nodeId for editing
const isEditable = title === 'Widgets' && nodeId != null;
const listHtml = items.map((item, index) => {
const name = escapeHtml(item.name || item.label || 'Unknown');
const type = item.type ? escapeHtml(item.type) : null;
const rawValue = item.value !== undefined ? String(item.value) : null;
const itemId = `${title.toLowerCase()}-${index}`;
// Detect toggle-style "button" widgets (have boolean values)
// These are buttons that toggle on/off state
const isButtonType = type && type.toLowerCase() === 'button';
const hasBooleanValue = rawValue === 'true' || rawValue === 'false';
const isToggleButton = isButtonType && hasBooleanValue;
// True action buttons have null/undefined values or are explicitly action-oriented
const isActionButton = isButtonType && !hasBooleanValue &&
(rawValue === 'null' || rawValue === 'undefined' || rawValue === null);
// Check if value contains a data URI image (chart/plot)
const isImageValue = rawValue && rawValue.includes("data:image");
const value = isImageValue ? rawValue : (rawValue ? escapeHtml(rawValue) : null);
// For toggle-style button widgets, render a toggle button with on/off visual indication
if (isToggleButton && nodeId != null) {
const isOn = rawValue === 'true';
const toggleColor = isOn ? 'var(--accent-green, #22c55e)' : 'var(--text-tertiary, #666)';
const toggleBg = isOn ? 'var(--accent-green, #22c55e)' : 'var(--bg-tertiary, #333)';
const toggleText = isOn ? 'ON' : 'OFF';
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">TOGGLE</span>
</div>
<button
id="${itemId}"
onclick="
const currentState = this.dataset.state === 'true';
const newState = !currentState;
this.dataset.state = String(newState);
sendWidgetEdit(${nodeId}, '${escapeHtml(item.name || '')}', newState);
this.textContent = newState ? '✓ ON' : '○ OFF';
this.style.background = newState ? 'var(--accent-green, #22c55e)' : 'var(--bg-tertiary, #333)';
this.style.color = newState ? 'var(--bg-primary, #1a1a1a)' : 'var(--text-secondary, #aaa)';
this.style.borderColor = newState ? 'var(--accent-green, #22c55e)' : 'var(--border-default, #555)';
this.style.transform='scale(0.95)';
setTimeout(() => this.style.transform='', 100);
"
data-state="${isOn}"
style="
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background: ${toggleBg};
color: ${isOn ? 'var(--bg-primary, #1a1a1a)' : 'var(--text-secondary, #aaa)'};
border: 2px solid ${isOn ? 'var(--accent-green, #22c55e)' : 'var(--border-default, #555)'};
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
"
onmouseover="this.style.transform='translateY(-1px)';"
onmouseout="this.style.transform='';"
>${isOn ? '✓ ON' : '○ OFF'}</button>
</div>`;
}
// For true action button widgets, render a clickable styled button
if (isActionButton && nodeId != null) {
return `
<div style="
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 6px;
border: 1px solid var(--border-subtle);
">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px;">
<span style="
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
">${name}</span>
<span style="
font-size: 8px;
padding: 2px 5px;
background: ${badgeColor}22;
color: ${badgeColor};
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
">ACTION</span>
</div>
<button
onclick="sendButtonClick(${nodeId}, '${escapeHtml(item.name || '')}'); this.style.transform='scale(0.95)'; setTimeout(() => this.style.transform='', 100);"
style="
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background: var(--accent-purple, #a855f7);
color: var(--bg-primary, #1a1a1a);
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
"
onmouseover="this.style.transform='translateY(-1px)'; this.style.boxShadow='0 3px 8px rgba(0, 0, 0, 0.25)';"
onmouseout="this.style.transform=''; this.style.boxShadow='0 1px 3px rgba(0, 0, 0, 0.2)';"
>▶ Click</button>
</div>`;
}
// For image values, render directly without escaping
if (isImageValue) {
return `
@@ -1390,6 +1597,16 @@
</div>`;
}
// Get widget options for combo boxes
const widgetOptions = item.options ? JSON.stringify(item.options).replace(/"/g, '&quot;') : '[]';
// Build onclick handler for editable widgets
const editableClass = isEditable ? 'widget-value-editable' : '';
const editableClick = isEditable && rawValue != null ?
`onclick="editWidgetValue(this, ${nodeId}, '${escapeHtml(item.name || '')}', ${JSON.stringify(rawValue).replace(/"/g, '&quot;')}, '${type || 'text'}', ${widgetOptions})"` : '';
const editableStyle = isEditable ? 'cursor: pointer;' : '';
const editableTitle = isEditable ? 'title="Click to edit"' : '';
return `
<div style="
padding: 10px 12px;
@@ -1418,42 +1635,27 @@
word-break: break-all;
max-width: 100%;
">${type}</span>` : ''}
${isEditable ? `<span style="
font-size: 9px;
color: var(--accent-amber);
margin-left: auto;
opacity: 0.7;
">✏️ editable</span>` : ''}
</div>
${value ? `<div style="position: relative;">
<div id="${itemId}" style="
<div id="${itemId}" class="${editableClass}" ${editableClick} ${editableTitle} style="
font-size: 11px;
color: var(--accent-blue);
font-weight: 500;
background: var(--bg-tertiary);
padding: 8px 36px 8px 10px;
padding: 8px 10px;
border-radius: 4px;
word-break: break-word;
white-space: pre-wrap;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
line-height: 1.4;
${editableStyle}
">${value}</div>
<button onclick="copyToClipboard('${rawValue.replace(/'/g, "\\'")}', this)" style="
position: absolute;
top: 6px;
right: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 4px;
padding: 4px 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: all 0.15s ease;
" onmouseover="this.style.opacity='1'; this.style.background='var(--bg-tertiary)'"
onmouseout="this.style.opacity='0.7'; this.style.background='var(--bg-secondary)'"
title="Copy to clipboard">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>` : ''}
</div>`;
}).join('');