Files
AEmotionStudio-ComfyUI-Magn…/src/shared/CustomDropdown.ts
T
AEmotionStudio da7c8b17f9 feat(ui): enhance keyboard navigation and hotkeys
- Added full keyboard support (Arrows, Enter, Esc) for all dropdown types
- Implemented global '*' hotkey to center canvas on currently inspected node
- Improved event handling to reliably intercept keys before ComfyUI canvas
- Fixed memory leaks in dropdown event listeners
2026-01-17 18:46:55 -08:00

365 lines
13 KiB
TypeScript

/**
* ComfyUI MagnifyGlass - Custom Dropdown
*
* A custom dropdown component that stays within viewport bounds.
* Replaces native select elements for better positioning control.
*/
export interface CustomDropdownConfig {
options: string[];
currentValue: string;
anchor: HTMLElement;
onChange: (value: string) => void;
onClose?: () => void;
theme?: string;
}
/**
* Custom dropdown that positions itself within viewport bounds.
* Use this instead of native <select> elements when positioning control is needed.
*/
export class CustomDropdown {
private dropdown: HTMLElement | null = null;
private config: CustomDropdownConfig;
private closeHandler: ((e: MouseEvent) => void) | null = null;
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
private scrollHandler: ((e: Event) => void) | null = null;
private currentIndex: number = 0; // Track active item for keyboard navigation
constructor(config: CustomDropdownConfig) {
this.config = config;
}
/**
* Show the dropdown
*/
show(): void {
this.hide(); // Close any existing dropdown first
const dropdown = document.createElement('div');
dropdown.className = `custom-dropdown-menu${this.config.theme ? ` theme-${this.config.theme}` : ''}`;
dropdown.style.cssText = `
position: fixed;
z-index: 100001;
overflow-y: auto;
background: var(--comfy-menu-bg, #1a1a1f);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
min-width: 120px;
`;
// Build dropdown items
this.config.options.forEach((optionValue) => {
const item = document.createElement('div');
item.className = 'custom-dropdown-item';
item.dataset.value = optionValue;
const isSelected = optionValue === this.config.currentValue;
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border-color, #333);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
color: ${isSelected ? 'var(--info-panel-accent-color, #4ecdc4)' : 'inherit'};
background: ${isSelected ? 'rgba(78, 205, 196, 0.1)' : 'transparent'};
white-space: nowrap;
`;
item.textContent = optionValue;
// Hover effect
item.addEventListener('mouseenter', () => {
if (!isSelected) {
item.style.background = 'var(--comfy-input-bg, #3a3a3a)';
}
});
item.addEventListener('mouseleave', () => {
item.style.background = isSelected ? 'rgba(78, 205, 196, 0.1)' : 'transparent';
});
// Selection handler
item.addEventListener('click', (e) => {
e.stopPropagation();
this.config.onChange(optionValue);
this.hide();
});
dropdown.appendChild(item);
});
// Remove border from last item
const lastItem = dropdown.lastElementChild as HTMLElement;
if (lastItem) {
lastItem.style.borderBottom = 'none';
}
document.body.appendChild(dropdown);
this.dropdown = dropdown;
// Initialize currentIndex based on currently selected value
this.currentIndex = this.config.options.indexOf(this.config.currentValue);
if (this.currentIndex < 0) this.currentIndex = 0;
// Make dropdown focusable for keyboard events
dropdown.setAttribute('tabindex', '-1');
// Position within viewport
this.positionWithinViewport();
// Scroll to selected item
this.scrollToSelected();
// Setup close handlers
this.setupCloseHandlers();
// Focus dropdown for keyboard navigation
dropdown.focus();
}
/**
* Hide the dropdown
*/
hide(): void {
if (this.dropdown && this.dropdown.parentNode) {
this.dropdown.parentNode.removeChild(this.dropdown);
this.dropdown = null;
}
this.cleanupHandlers();
this.config.onClose?.();
}
/**
* Position dropdown within viewport bounds
*/
private positionWithinViewport(): void {
if (!this.dropdown) return;
const margin = 10;
const gap = 4;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const anchorRect = this.config.anchor.getBoundingClientRect();
// Temporarily remove constraints to measure natural size
this.dropdown.style.maxWidth = 'none';
this.dropdown.style.maxHeight = 'none';
const naturalRect = this.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;
// 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);
} else {
// Position above anchor
maxHeight = Math.max(100, spaceAbove);
top = anchorRect.top - gap - Math.min(naturalRect.height, maxHeight);
}
// Horizontal positioning: prefer left-aligned with anchor, shift if needed
let left = anchorRect.left;
let maxWidth: number;
if (naturalRect.width <= spaceRight) {
// Fits when left-aligned
maxWidth = spaceRight;
} else {
// Shift left to fit, or constrain width
left = Math.max(margin, viewportWidth - naturalRect.width - margin);
maxWidth = viewportWidth - margin * 2;
}
// Ensure minimum width matches anchor
const minWidth = Math.max(120, anchorRect.width);
// Apply final position and constraints
this.dropdown.style.top = `${Math.max(margin, top)}px`;
this.dropdown.style.left = `${Math.max(margin, left)}px`;
this.dropdown.style.minWidth = `${minWidth}px`;
this.dropdown.style.maxWidth = `${maxWidth}px`;
this.dropdown.style.maxHeight = `${maxHeight}px`;
}
/**
* Scroll to the currently selected item
*/
private scrollToSelected(): void {
if (!this.dropdown) return;
const selectedItem = this.dropdown.querySelector(
`[data-value="${CSS.escape(this.config.currentValue)}"]`
) as HTMLElement;
if (selectedItem) {
// Scroll the item into view, centered if possible
selectedItem.scrollIntoView({ block: 'nearest' });
}
}
/**
* Setup event handlers for closing the dropdown
*/
private setupCloseHandlers(): void {
// Close on click/mousedown outside - use capture phase to ensure we get the event
this.closeHandler = (e: MouseEvent) => {
if (this.dropdown &&
!this.dropdown.contains(e.target as Node) &&
!this.config.anchor.contains(e.target as Node)) {
this.hide();
}
};
// Helper to update active item visually
const updateActiveItem = (newIndex: number) => {
if (!this.dropdown) return;
const items = this.dropdown.querySelectorAll('.custom-dropdown-item');
if (items.length === 0) return;
// Clamp index
if (newIndex < 0) newIndex = 0;
if (newIndex >= items.length) newIndex = items.length - 1;
// Remove highlight from old item
if (this.currentIndex >= 0 && this.currentIndex < items.length) {
const oldItem = items[this.currentIndex] as HTMLElement;
const isOldSelected = this.config.options[this.currentIndex] === this.config.currentValue;
oldItem.style.background = isOldSelected ? 'rgba(78, 205, 196, 0.1)' : 'transparent';
}
this.currentIndex = newIndex;
// Highlight new item
const newItem = items[this.currentIndex] as HTMLElement;
newItem.style.background = 'var(--comfy-input-bg, #3a3a3a)';
newItem.scrollIntoView({ block: 'nearest' });
};
// Key handler for navigation and closing
this.keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.hide();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
updateActiveItem(this.currentIndex + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
updateActiveItem(this.currentIndex - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (this.currentIndex >= 0 && this.currentIndex < this.config.options.length) {
this.config.onChange(this.config.options[this.currentIndex]);
this.hide();
}
}
};
// Close on scroll outside the dropdown (not when scrolling within it)
this.scrollHandler = (e: Event) => {
// Don't close if scrolling within the dropdown itself
if (this.dropdown && e.target instanceof Node && this.dropdown.contains(e.target)) {
return;
}
this.hide();
};
// Delay adding listeners to avoid immediate closure from the opening click
setTimeout(() => {
// Use window and capture phase for key events to intercept before ComfyUI
window.addEventListener('keydown', this.keyHandler!, true);
// Use capture phase (true) to get events before they can be stopped
document.addEventListener('mousedown', this.closeHandler!, true);
document.addEventListener('click', this.closeHandler!, true);
window.addEventListener('scroll', this.scrollHandler!, true);
}, 50);
}
/**
* Cleanup event handlers
*/
private cleanupHandlers(): void {
if (this.closeHandler) {
document.removeEventListener('mousedown', this.closeHandler, true);
document.removeEventListener('click', this.closeHandler, true);
this.closeHandler = null;
}
if (this.keyHandler) {
window.removeEventListener('keydown', this.keyHandler, true); // Match window listener
this.keyHandler = null;
}
if (this.scrollHandler) {
window.removeEventListener('scroll', this.scrollHandler, true);
this.scrollHandler = null;
}
}
}
/**
* Create a custom dropdown trigger element that looks like a select
*/
export function createDropdownTrigger(
currentValue: string,
className: string = 'custom-dropdown-trigger'
): HTMLElement {
const trigger = document.createElement('div');
trigger.className = className;
trigger.setAttribute('tabindex', '0');
trigger.setAttribute('role', 'combobox');
trigger.setAttribute('aria-haspopup', 'listbox');
const valueSpan = document.createElement('span');
valueSpan.className = 'custom-dropdown-value';
valueSpan.textContent = currentValue;
valueSpan.style.cssText = `
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const arrow = document.createElement('span');
arrow.className = 'custom-dropdown-arrow';
arrow.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`;
arrow.style.cssText = `
flex-shrink: 0;
display: flex;
align-items: center;
opacity: 0.7;
`;
trigger.appendChild(valueSpan);
trigger.appendChild(arrow);
return trigger;
}
/**
* Update the displayed value in a dropdown trigger
*/
export function updateDropdownTriggerValue(trigger: HTMLElement, value: string): void {
const valueSpan = trigger.querySelector('.custom-dropdown-value');
if (valueSpan) {
valueSpan.textContent = value;
}
}