feat(ui): Implement inline widget controls and drag value adjustments

- Added 'InlineControlFactory' to create inline toggle switches and combo boxes for supported widget types, replacing the click-to-edit modal for these types.
- Added 'DragValueController' to enable adjusting numeric values by dragging on the value display.
- Updated 'UIManager' to integrate these new controls, manage their lifecycle, and properly clean them up.
- Enhanced 'WidgetEditorFactory' to handle Enter key for confirming edits.
- Removed the copy button from string values to reduce clutter.
- Updated 'index.ts' to export the new components.
This commit is contained in:
AEmotionStudio
2026-01-16 04:19:29 -08:00
parent fc8b685bef
commit d2ea6d965f
16 changed files with 1791 additions and 100 deletions
+4 -1
View File
@@ -43,4 +43,7 @@ DEBUG_SUMMARY.md
# Environment
.env
.env.local
.env.local
# Images
pasted file.png
+311 -15
View File
@@ -46,10 +46,12 @@
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);
overflow: hidden;
overflow: visible;
font-size: 16px;
line-height: 1.6;
pointer-events: auto;
display: flex;
flex-direction: column;
}
.magnify-info-panel * {
@@ -165,10 +167,44 @@
.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);
}
/* 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.is-expanded:not(.panel-minimized) .panel-content {
max-height: none !important;
overflow: visible !important;
}
/*
* STICKY MODE (PERSIST) OVERRIDES
* When sticky mode is active, we enforce the max-height and enable scrolling.
* This ensures the panel respects user-configured height and can scroll.
*/
.magnify-info-panel.persist-active:not(.panel-minimized) {
/* Use the CSS variable set by JS for max-height */
max-height: var(--panel-max-height, 600px) !important;
overflow: hidden !important;
}
.magnify-info-panel.persist-active:not(.panel-minimized) .panel-content {
/* Allow scrolling inside the content area */
overflow-y: auto !important;
/* Reset max-height so it fills the parent (which is constrained) */
max-height: none !important;
/* Ensure it takes available space */
flex: 1 1 auto;
}
.floating-controls {
position: fixed;
display: none;
@@ -310,8 +346,9 @@
.panel-content {
padding: 0;
max-height: calc(100% - 60px);
overflow-y: auto;
flex: 1;
min-height: 0;
overflow: visible;
scrollbar-width: thin;
}
@@ -853,16 +890,33 @@
width: 100%;
cursor: pointer;
appearance: none;
/* Match the blue text theme style */
background: transparent;
border: 1px solid transparent;
color: var(--info-panel-accent-color, #74b9ff);
font-size: 14px;
padding: 4px 24px 4px 10px;
border-radius: 6px;
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 8px center;
background-size: 14px;
padding-right: 28px;
background-position: right 6px center;
background-size: 12px;
}
.widget-editor-select:hover {
background-color: rgba(116, 185, 255, 0.12);
border-color: transparent;
}
.widget-editor-select:focus {
background-color: rgba(116, 185, 255, 0.15);
border-color: var(--info-panel-accent-color, #74b9ff);
box-shadow: 0 0 0 2px rgba(116, 185, 255, 0.2);
}
.widget-editor-select option {
background: #2a2a2a;
color: #e0e0e0;
background: #1a1a1f;
color: var(--info-panel-accent-color, #74b9ff);
padding: 8px;
}
@@ -1049,18 +1103,31 @@
/* Theme-specific adjustments for editors */
.magnify-info-panel.theme-light .widget-editor-input,
.magnify-info-panel.theme-light .widget-editor-textarea,
.magnify-info-panel.theme-light .widget-editor-select {
.magnify-info-panel.theme-light .widget-editor-textarea {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.2);
}
.magnify-info-panel.theme-light .widget-editor-input:focus,
.magnify-info-panel.theme-light .widget-editor-textarea:focus,
.magnify-info-panel.theme-light .widget-editor-select:focus {
.magnify-info-panel.theme-light .widget-editor-textarea:focus {
background: rgba(0, 0, 0, 0.08);
}
.magnify-info-panel.theme-light .widget-editor-select {
background: transparent;
border-color: transparent;
color: var(--info-panel-accent-color, #0066cc);
}
.magnify-info-panel.theme-light .widget-editor-select:hover {
background-color: rgba(0, 102, 204, 0.08);
}
.magnify-info-panel.theme-light .widget-editor-select option {
background: #ffffff;
color: var(--info-panel-accent-color, #0066cc);
}
.magnify-info-panel.theme-light .widget-editor-stepper {
border-color: rgba(0, 0, 0, 0.15);
background: rgba(0, 0, 0, 0.05);
@@ -1072,14 +1139,243 @@
}
.magnify-info-panel.theme-github .widget-editor-input,
.magnify-info-panel.theme-github .widget-editor-textarea,
.magnify-info-panel.theme-github .widget-editor-select {
.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: #24292e;
color: var(--info-panel-accent-color, #0366d6);
}
/* ==========================================================================
Inline Controls - Compact controls that render directly in rows
========================================================================== */
.inline-control-container {
display: flex;
align-items: center;
margin-left: auto;
}
.inline-control {
display: inline-flex;
align-items: center;
}
/* Inline Toggle - Compact version */
.inline-toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
}
.inline-toggle-input {
opacity: 0;
width: 0;
height: 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);
}
.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 blue text theme */
.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 the blue text theme style */
background: transparent;
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;
}
.inline-dropdown-select:hover {
background-color: rgba(116, 185, 255, 0.12);
border-color: transparent;
}
.inline-dropdown-select:focus {
outline: none;
background-color: rgba(116, 185, 255, 0.15);
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;
}
/* 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;
transition: opacity 0.2s ease;
}
.info-row.draggable:hover::after {
opacity: 1;
}
.info-row.dragging {
background: rgba(116, 185, 255, 0.1) !important;
}
.info-row.dragging .info-value {
color: var(--info-panel-accent-color, #74b9ff);
font-weight: 600;
}
.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;
transition: opacity 0.2s ease;
}
.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);
}
+112 -41
View File
@@ -20,7 +20,14 @@ import {
type ParameterItem
} from './NodeDataExtractor';
import { NodeSelector, type NodeListEntry, type NodeExecOrderEntry } from './NodeSelector';
import { WidgetEditorFactory, WidgetSyncManager, type WidgetEditorInstance } from './widget-editors';
import {
WidgetEditorFactory,
WidgetSyncManager,
InlineControlFactory,
DragValueController,
type WidgetEditorInstance,
type InlineControlInstance
} from './widget-editors';
interface InfoPanelElements {
panel: HTMLDivElement | null;
@@ -41,6 +48,10 @@ export class UIManager {
onNodeSelected: ((nodeId: number) => void) | null = null;
// Track active widget editors for cleanup
private activeEditors: Map<string, WidgetEditorInstance> = new Map();
// Track active inline controls for cleanup
private activeInlineControls: Map<string, InlineControlInstance> = new Map();
// Track active drag controllers for cleanup
private activeDragControllers: Map<string, DragValueController> = new Map();
constructor(stateManager: StateManager) {
this.stateManager = stateManager;
@@ -718,9 +729,17 @@ export class UIManager {
/**
* Display information in the panel.
* @param info
* @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);
return;
}
const sections = this.buildSections(info);
this.renderSections(sections);
this.updateSectionStates();
@@ -920,22 +939,12 @@ export class UIManager {
// Only show dropdown arrow for actual dropdowns, not actions like zoom
const dropdownIcon = (item.clickable && item.clickable !== 'zoom') ? '<span class="dropdown-indicator" style="margin-left: 4px; opacity: 0.6; font-size: 10px;">▼</span>' : '';
// Show copy button for string values that are long enough to be worth copying
const rawValue = String(item.value || '');
const showCopyBtn = !item.clickable && rawValue.length > 3 && typeof item.value === 'string';
const copyBtnHtml = showCopyBtn ? `
<button class="copy-btn" data-copy-value="${escapeHtml(rawValue)}" title="Copy to clipboard" aria-label="Copy to clipboard">
<svg 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>` : '';
return `
<div class="info-row ${clickableClass} ${editableClass}" ${clickableAttr} ${nodeIdAttr} ${editableAttrs} style="${item.clickable ? 'cursor: pointer;' : ''}">
<span class="info-label">${escapeHtml(item.label)}</span>
<span class="info-value ${valueClass} original" ${valueAttributes}>${value}${dropdownIcon}</span>
<div class="inline-control-container" style="display: none;"></div>
<div class="widget-editor-container" style="display: none;"></div>
${copyBtnHtml}
</div>`;
}).join('')}
</div>
@@ -952,7 +961,7 @@ export class UIManager {
}
/**
* Cleanup active widget editors.
* Cleanup active widget editors, inline controls, and drag controllers.
*/
private cleanupEditors(): void {
this.activeEditors.forEach(editor => {
@@ -963,10 +972,29 @@ export class UIManager {
}
});
this.activeEditors.clear();
this.activeInlineControls.forEach(control => {
try {
control.destroy();
} catch (e) {
// Ignore cleanup errors
}
});
this.activeInlineControls.clear();
this.activeDragControllers.forEach(controller => {
try {
controller.destroy();
} catch (e) {
// Ignore cleanup errors
}
});
this.activeDragControllers.clear();
}
/**
* Attach click handlers to editable widget rows.
* Also creates inline controls for toggle/combo widgets and drag controllers.
*/
private attachEditableRowHandlers(): void {
if (!this.elements.content) return;
@@ -976,14 +1004,78 @@ export class UIManager {
const rowEl = row as HTMLElement;
const valueEl = rowEl.querySelector('.info-value.original') as HTMLElement;
const editorContainer = rowEl.querySelector('.widget-editor-container') as HTMLElement;
const inlineContainer = rowEl.querySelector('.inline-control-container') as HTMLElement;
if (!valueEl || !editorContainer) return;
if (!valueEl) return;
// Click on value to enter edit mode
valueEl.addEventListener('click', (e) => {
e.stopPropagation();
this.enterEditMode(rowEl, valueEl, editorContainer);
});
const nodeId = parseInt(rowEl.dataset.nodeId || '0', 10);
const widgetName = rowEl.dataset.widgetName || '';
const widgetType = rowEl.dataset.widgetType || 'text';
const rawValue = rowEl.dataset.rawValue;
// Parse constraints
let constraints: any = {};
try {
const constraintsStr = rowEl.dataset.constraints;
if (constraintsStr) {
constraints = JSON.parse(constraintsStr);
}
} catch (e) {
// Ignore parse errors
}
if (isNaN(nodeId) || !widgetName) return;
const controlKey = `${nodeId}:${widgetName}`;
// Check if this widget type should use inline controls
if (InlineControlFactory.shouldUseInlineControl(widgetType) && inlineContainer) {
// Create inline control
const control = InlineControlFactory.createControl({
nodeId,
widgetName,
widgetType,
currentValue: widgetType.toLowerCase() === 'boolean' || widgetType.toLowerCase() === 'toggle'
? rawValue === 'true'
: rawValue,
constraints,
onChange: (value) => {
rowEl.dataset.rawValue = String(value);
}
});
if (control) {
this.activeInlineControls.set(controlKey, control);
inlineContainer.appendChild(control.element);
inlineContainer.style.display = 'flex';
// Hide the text value display
valueEl.style.display = 'none';
}
} else if (editorContainer) {
// Fall back to click-to-edit for text/number types
valueEl.addEventListener('click', (e) => {
e.stopPropagation();
this.enterEditMode(rowEl, valueEl, editorContainer);
});
}
// Attach drag controller if widget type supports it and we don't have an inline control
if (DragValueController.isTypeSupported(widgetType) &&
!InlineControlFactory.shouldUseInlineControl(widgetType)) {
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);
}
});
this.activeDragControllers.set(controlKey, dragController);
}
});
}
@@ -1122,27 +1214,6 @@ export class UIManager {
(row as HTMLElement).style.background = '';
});
});
// Add click handlers for copy buttons
const copyButtons = this.elements.content.querySelectorAll('.copy-btn');
copyButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const value = (btn as HTMLElement).dataset.copyValue || '';
navigator.clipboard.writeText(value).then(() => {
// Show success feedback
btn.classList.add('copied');
const originalHtml = btn.innerHTML;
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
setTimeout(() => {
btn.innerHTML = originalHtml;
btn.classList.remove('copied');
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
});
});
}
/**
@@ -0,0 +1,288 @@
/**
* 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.
*/
import { WidgetSyncManager, WidgetConstraints } from './WidgetSyncManager';
import { Logger } from '../../shared/logger';
/**
* Configuration for drag behavior
*/
export interface DragConfig {
nodeId: number;
widgetName: string;
widgetType: string;
currentValue: unknown;
constraints?: WidgetConstraints;
onChange?: (value: unknown) => void;
}
/**
* Default sensitivity settings
*/
const DRAG_SENSITIVITY = {
// Pixels per unit change for integers
intPixelsPerUnit: 8,
// Pixels per 0.1 change for floats
floatPixelsPerTenth: 10,
// Minimum pixels to trigger option cycle for combos
comboThreshold: 40,
};
/**
* Drag Value Controller
* Manages drag interactions for inline value editing
*/
export class DragValueController {
private element: HTMLElement;
private config: DragConfig;
private isDragging: boolean = false;
private startX: number = 0;
private startValue: number = 0;
private startOptionIndex: number = 0;
private accumulatedDelta: number = 0;
// Bound event handlers for cleanup
private boundPointerMove: (e: PointerEvent) => void;
private boundPointerUp: (e: PointerEvent) => void;
constructor(element: HTMLElement, config: DragConfig) {
this.element = element;
this.config = config;
this.boundPointerMove = this.onPointerMove.bind(this);
this.boundPointerUp = this.onPointerUp.bind(this);
this.init();
}
/**
* Initialize drag handling
*/
private init(): void {
this.element.classList.add('draggable');
this.element.addEventListener('pointerdown', this.onPointerDown.bind(this));
// 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);
}
/**
* Handle pointer down - start drag tracking
*/
private onPointerDown(e: PointerEvent): void {
// Only handle left mouse button or touch
if (e.button !== 0) return;
// Don't start drag if clicking on interactive elements or value/editor areas
// Only the label area should initiate drag - all controls should be clickable
const target = e.target as HTMLElement;
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')) {
return;
}
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;
}
// Add dragging class
this.element.classList.add('dragging');
// Capture pointer for reliable tracking
this.element.setPointerCapture(e.pointerId);
// Add document-level listeners
document.addEventListener('pointermove', this.boundPointerMove);
document.addEventListener('pointerup', this.boundPointerUp);
e.preventDefault();
Logger.debug(`[DragValue] Started drag on ${this.config.widgetName}`);
}
/**
* Handle pointer move - update value based on drag distance
*/
private onPointerMove(e: PointerEvent): void {
if (!this.isDragging) return;
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);
}
}
/**
* Handle number value drag
*/
private handleNumberDrag(deltaX: number): void {
const constraints = this.config.constraints;
const isInt = this.config.widgetType.toLowerCase() === 'int';
// Calculate sensitivity based on type and constraints
let sensitivity: number;
if (isInt) {
sensitivity = DRAG_SENSITIVITY.intPixelsPerUnit;
} else {
// For floats, adjust based on step
const step = constraints?.step ?? 0.1;
sensitivity = DRAG_SENSITIVITY.floatPixelsPerTenth / (step / 0.1);
}
// Calculate new value
const units = deltaX / sensitivity;
const step = constraints?.step ?? (isInt ? 1 : 0.1);
let newValue = this.startValue + (units * step);
// Apply constraints
if (constraints?.min !== undefined) {
newValue = Math.max(constraints.min, newValue);
}
if (constraints?.max !== undefined) {
newValue = Math.min(constraints.max, newValue);
}
// Round to step precision
if (step) {
newValue = Math.round(newValue / step) * step;
}
// Round for display
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);
}
}
/**
* Handle pointer up - end drag
*/
private onPointerUp(e: PointerEvent): void {
if (!this.isDragging) return;
this.isDragging = false;
this.element.classList.remove('dragging');
// Release pointer capture
try {
this.element.releasePointerCapture(e.pointerId);
} catch {
// Ignore if already released
}
// Remove document-level listeners
document.removeEventListener('pointermove', this.boundPointerMove);
document.removeEventListener('pointerup', this.boundPointerUp);
Logger.debug(`[DragValue] Ended drag on ${this.config.widgetName}`);
}
/**
* 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 document listeners if still attached
document.removeEventListener('pointermove', this.boundPointerMove);
document.removeEventListener('pointerup', this.boundPointerUp);
}
/**
* Check if a widget type supports drag editing
*/
static isTypeSupported(widgetType: string): boolean {
const type = widgetType.toLowerCase();
return [
'number', 'int', 'float', 'slider', 'combo'
].includes(type);
}
}
@@ -0,0 +1,149 @@
/**
* ComfyUI MagnifyGlass - Inline Control Factory
*
* Creates compact inline controls that render directly in inspector rows
* for immediate value editing without entering edit mode.
*/
import { WidgetSyncManager, WidgetConstraints } from './WidgetSyncManager';
import { Logger } from '../../shared/logger';
/**
* Configuration for creating an inline control
*/
export interface InlineControlConfig {
nodeId: number;
widgetName: string;
widgetType: string;
currentValue: unknown;
constraints?: WidgetConstraints;
onChange?: (value: unknown) => void;
}
/**
* Interface for inline control instances
*/
export interface InlineControlInstance {
element: HTMLElement;
getValue(): unknown;
setValue(value: unknown): void;
destroy(): void;
}
/**
* Inline Control Factory
* Creates compact inline controls for immediate value editing
*/
export class InlineControlFactory {
/**
* Check if a widget type should use inline controls
*/
static shouldUseInlineControl(widgetType: string): boolean {
const type = widgetType.toLowerCase();
return type === 'toggle' || type === 'boolean' || type === 'combo';
}
/**
* Create an inline control for the given configuration
*/
static createControl(config: InlineControlConfig): InlineControlInstance | null {
const type = config.widgetType.toLowerCase();
switch (type) {
case 'toggle':
case 'boolean':
return this.createInlineToggle(config);
case 'combo':
return this.createInlineDropdown(config);
default:
return null;
}
}
/**
* Create compact inline toggle for boolean values
*/
private static createInlineToggle(config: InlineControlConfig): InlineControlInstance {
const container = document.createElement('div');
container.className = 'inline-control inline-toggle';
const label = document.createElement('label');
label.className = 'inline-toggle-switch';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'inline-toggle-input';
// Handle boolean coercion properly
const boolValue = config.currentValue === true || config.currentValue === 'true';
checkbox.checked = boolValue;
const slider = document.createElement('span');
slider.className = 'inline-toggle-slider';
// Stop propagation to prevent row click handlers
container.addEventListener('click', (e) => e.stopPropagation());
// Sync on change
checkbox.addEventListener('change', () => {
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, checkbox.checked);
config.onChange?.(checkbox.checked);
Logger.debug(`[InlineControl] Toggle ${config.widgetName}: ${checkbox.checked}`);
});
label.appendChild(checkbox);
label.appendChild(slider);
container.appendChild(label);
return {
element: container,
getValue: () => checkbox.checked,
setValue: (v) => { checkbox.checked = Boolean(v); },
destroy: () => { container.remove(); }
};
}
/**
* Create compact inline dropdown for combo values
*/
private static createInlineDropdown(config: InlineControlConfig): InlineControlInstance {
const container = document.createElement('div');
container.className = 'inline-control inline-dropdown';
const select = document.createElement('select');
select.className = 'inline-dropdown-select';
// Populate options
const options = config.constraints?.options ?? [];
const currentValueStr = String(config.currentValue);
for (const opt of options) {
const option = document.createElement('option');
const optStr = String(opt);
option.value = optStr;
option.textContent = optStr;
if (optStr === currentValueStr) {
option.selected = true;
}
select.appendChild(option);
}
// Stop propagation to prevent row click handlers
container.addEventListener('click', (e) => e.stopPropagation());
// Sync on change
select.addEventListener('change', () => {
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, select.value);
config.onChange?.(select.value);
Logger.debug(`[InlineControl] Dropdown ${config.widgetName}: ${select.value}`);
});
container.appendChild(select);
return {
element: container,
getValue: () => select.value,
setValue: (v) => { select.value = String(v); },
destroy: () => { container.remove(); }
};
}
}
@@ -150,9 +150,10 @@ export class WidgetEditorFactory {
syncValue();
});
// Handle keyboard
// Handle keyboard - Enter to confirm, Escape to cancel
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' || e.key === 'Enter') {
e.preventDefault();
input.blur();
}
});
@@ -282,7 +283,8 @@ export class WidgetEditorFactory {
select.addEventListener('blur', () => config.onBlur?.());
select.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' || e.key === 'Enter') {
e.preventDefault();
select.blur();
}
});
+2
View File
@@ -6,3 +6,5 @@
export { WidgetSyncManager, type WidgetConstraints, type SyncResult } from './WidgetSyncManager';
export { WidgetEditorFactory, type WidgetEditorConfig, type WidgetEditorInstance } from './WidgetEditorFactory';
export { InlineControlFactory, type InlineControlConfig, type InlineControlInstance } from './InlineControlFactory';
export { DragValueController, type DragConfig } from './DragValueController';
+528 -1
View File
@@ -851,4 +851,531 @@
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 - blue text theme style */
.widget-editor-combo {
width: 100%;
}
.widget-editor-select {
width: 100%;
cursor: pointer;
appearance: none;
background: transparent;
border: 1px solid transparent;
color: var(--info-panel-accent-color, #74b9ff);
font-size: 14px;
padding: 4px 24px 4px 10px;
border-radius: 6px;
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;
}
.widget-editor-select:hover {
background-color: rgba(116, 185, 255, 0.12);
border-color: transparent;
}
.widget-editor-select:focus {
background-color: rgba(116, 185, 255, 0.15);
border-color: var(--info-panel-accent-color, #74b9ff);
box-shadow: 0 0 0 2px rgba(116, 185, 255, 0.2);
}
.widget-editor-select option {
background: #1a1a1f;
color: var(--info-panel-accent-color, #74b9ff);
padding: 8px;
}
/* Boolean toggle editor */
.widget-editor-boolean {
justify-content: flex-start;
}
.widget-editor-toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
cursor: pointer;
}
.widget-editor-checkbox {
opacity: 0;
width: 0;
height: 0;
}
.widget-editor-toggle-slider {
position: absolute;
cursor: pointer;
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);
}
.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.editing {
background: rgba(116, 185, 255, 0.1);
padding: 8px;
margin: 4px -8px;
border-radius: 6px;
}
.info-row.editing .info-label {
margin-bottom: 4px;
}
/* Hide original value when editing */
.info-row.editing .info-value.original {
display: none;
}
/* ==========================================================================
Inline Controls - Compact controls that render directly in rows
========================================================================== */
.inline-control-container {
display: flex;
align-items: center;
margin-left: auto;
}
.inline-control {
display: inline-flex;
align-items: center;
}
/* Inline Toggle - Compact version */
.inline-toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
}
.inline-toggle-input {
opacity: 0;
width: 0;
height: 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);
}
.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 - blue text theme style */
.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;
background: transparent;
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;
}
.inline-dropdown-select:hover {
background-color: rgba(116, 185, 255, 0.12);
border-color: transparent;
}
.inline-dropdown-select:focus {
outline: none;
background-color: rgba(116, 185, 255, 0.15);
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;
}
/* 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;
transition: opacity 0.2s ease;
}
.info-row.draggable:hover::after {
opacity: 1;
}
.info-row.dragging {
background: rgba(116, 185, 255, 0.1) !important;
}
.info-row.dragging .info-value {
color: var(--info-panel-accent-color, #74b9ff);
font-weight: 600;
}
.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;
transition: opacity 0.2s ease;
}
.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);
}
+81 -35
View File
@@ -10,6 +10,8 @@ import { getCheckpointInfo, getImageInfo, getTextBoxContent, getImportantNodePar
import { NodeSelector } from "./NodeSelector.js";
import { WidgetSyncManager } from "./widget-editors/WidgetSyncManager.js";
import { WidgetEditorFactory } from "./widget-editors/WidgetEditorFactory.js";
import { InlineControlFactory } from "./widget-editors/InlineControlFactory.js";
import { DragValueController } from "./widget-editors/DragValueController.js";
class UIManager {
constructor(stateManager) {
__publicField(this, "stateManager");
@@ -19,6 +21,10 @@ class UIManager {
__publicField(this, "onNodeSelected", null);
// Track active widget editors for cleanup
__publicField(this, "activeEditors", /* @__PURE__ */ new Map());
// Track active inline controls for cleanup
__publicField(this, "activeInlineControls", /* @__PURE__ */ new Map());
// Track active drag controllers for cleanup
__publicField(this, "activeDragControllers", /* @__PURE__ */ new Map());
this.stateManager = stateManager;
this.elements = {
panel: null,
@@ -516,9 +522,13 @@ class UIManager {
}
/**
* Display information in the panel.
* @param info
* @param info
*/
displayInfo(info) {
if (this.activeEditors.size > 0) {
this.updateHeaderSubtitle(info);
return;
}
const sections = this.buildSections(info);
this.renderSections(sections);
this.updateSectionStates();
@@ -662,21 +672,12 @@ class UIManager {
const rawValueStr = isEditable ? typeof item.rawValue === "boolean" ? item.rawValue ? "true" : "false" : escapeHtml(String(item.rawValue ?? "")) : "";
const editableAttrs = isEditable ? `data-editable="true" data-widget-name="${escapeHtml(item.widgetName)}" data-widget-type="${escapeHtml(item.widgetType || "text")}" data-raw-value="${rawValueStr}" data-constraints="${constraintsJson}"` : "";
const dropdownIcon = item.clickable && item.clickable !== "zoom" ? '<span class="dropdown-indicator" style="margin-left: 4px; opacity: 0.6; font-size: 10px;">▼</span>' : "";
const rawValue = String(item.value || "");
const showCopyBtn = !item.clickable && rawValue.length > 3 && typeof item.value === "string";
const copyBtnHtml = showCopyBtn ? `
<button class="copy-btn" data-copy-value="${escapeHtml(rawValue)}" title="Copy to clipboard" aria-label="Copy to clipboard">
<svg 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>` : "";
return `
<div class="info-row ${clickableClass} ${editableClass}" ${clickableAttr} ${nodeIdAttr} ${editableAttrs} style="${item.clickable ? "cursor: pointer;" : ""}">
<span class="info-label">${escapeHtml(item.label)}</span>
<span class="info-value ${valueClass} original" ${valueAttributes}>${value}${dropdownIcon}</span>
<div class="inline-control-container" style="display: none;"></div>
<div class="widget-editor-container" style="display: none;"></div>
${copyBtnHtml}
</div>`;
}).join("")}
</div>
@@ -688,7 +689,7 @@ class UIManager {
}
}
/**
* Cleanup active widget editors.
* Cleanup active widget editors, inline controls, and drag controllers.
*/
cleanupEditors() {
this.activeEditors.forEach((editor) => {
@@ -698,9 +699,24 @@ class UIManager {
}
});
this.activeEditors.clear();
this.activeInlineControls.forEach((control) => {
try {
control.destroy();
} catch (e) {
}
});
this.activeInlineControls.clear();
this.activeDragControllers.forEach((controller) => {
try {
controller.destroy();
} catch (e) {
}
});
this.activeDragControllers.clear();
}
/**
* Attach click handlers to editable widget rows.
* Also creates inline controls for toggle/combo widgets and drag controllers.
*/
attachEditableRowHandlers() {
if (!this.elements.content) return;
@@ -709,11 +725,59 @@ class UIManager {
const rowEl = row;
const valueEl = rowEl.querySelector(".info-value.original");
const editorContainer = rowEl.querySelector(".widget-editor-container");
if (!valueEl || !editorContainer) return;
valueEl.addEventListener("click", (e) => {
e.stopPropagation();
this.enterEditMode(rowEl, valueEl, editorContainer);
});
const inlineContainer = rowEl.querySelector(".inline-control-container");
if (!valueEl) return;
const nodeId = parseInt(rowEl.dataset.nodeId || "0", 10);
const widgetName = rowEl.dataset.widgetName || "";
const widgetType = rowEl.dataset.widgetType || "text";
const rawValue = rowEl.dataset.rawValue;
let constraints = {};
try {
const constraintsStr = rowEl.dataset.constraints;
if (constraintsStr) {
constraints = JSON.parse(constraintsStr);
}
} catch (e) {
}
if (isNaN(nodeId) || !widgetName) return;
const controlKey = `${nodeId}:${widgetName}`;
if (InlineControlFactory.shouldUseInlineControl(widgetType) && inlineContainer) {
const control = InlineControlFactory.createControl({
nodeId,
widgetName,
widgetType,
currentValue: widgetType.toLowerCase() === "boolean" || widgetType.toLowerCase() === "toggle" ? rawValue === "true" : rawValue,
constraints,
onChange: (value) => {
rowEl.dataset.rawValue = String(value);
}
});
if (control) {
this.activeInlineControls.set(controlKey, control);
inlineContainer.appendChild(control.element);
inlineContainer.style.display = "flex";
valueEl.style.display = "none";
}
} else if (editorContainer) {
valueEl.addEventListener("click", (e) => {
e.stopPropagation();
this.enterEditMode(rowEl, valueEl, editorContainer);
});
}
if (DragValueController.isTypeSupported(widgetType) && !InlineControlFactory.shouldUseInlineControl(widgetType)) {
const dragController = new DragValueController(rowEl, {
nodeId,
widgetName,
widgetType,
currentValue: rawValue,
constraints,
onChange: (value) => {
rowEl.dataset.rawValue = String(value);
valueEl.textContent = formatWidgetValue(value);
}
});
this.activeDragControllers.set(controlKey, dragController);
}
});
}
/**
@@ -817,24 +881,6 @@ class UIManager {
row.style.background = "";
});
});
const copyButtons = this.elements.content.querySelectorAll(".copy-btn");
copyButtons.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const value = btn.dataset.copyValue || "";
navigator.clipboard.writeText(value).then(() => {
btn.classList.add("copied");
const originalHtml = btn.innerHTML;
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
setTimeout(() => {
btn.innerHTML = originalHtml;
btn.classList.remove("copied");
}, 1500);
}).catch((err) => {
console.error("Failed to copy:", err);
});
});
});
}
/**
* Update section expansion states.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,197 @@
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 { WidgetSyncManager } from "./WidgetSyncManager.js";
import { Logger } from "../../shared/logger.js";
const DRAG_SENSITIVITY = {
// Pixels per unit change for integers
intPixelsPerUnit: 8,
// Pixels per 0.1 change for floats
floatPixelsPerTenth: 10,
// Minimum pixels to trigger option cycle for combos
comboThreshold: 40
};
class DragValueController {
constructor(element, config) {
__publicField(this, "element");
__publicField(this, "config");
__publicField(this, "isDragging", false);
__publicField(this, "startX", 0);
__publicField(this, "startValue", 0);
__publicField(this, "startOptionIndex", 0);
__publicField(this, "accumulatedDelta", 0);
// Bound event handlers for cleanup
__publicField(this, "boundPointerMove");
__publicField(this, "boundPointerUp");
this.element = element;
this.config = config;
this.boundPointerMove = this.onPointerMove.bind(this);
this.boundPointerUp = this.onPointerUp.bind(this);
this.init();
}
/**
* Initialize drag handling
*/
init() {
this.element.classList.add("draggable");
this.element.addEventListener("pointerdown", this.onPointerDown.bind(this));
}
/**
* 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")) {
return;
}
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.element.classList.add("dragging");
this.element.setPointerCapture(e.pointerId);
document.addEventListener("pointermove", this.boundPointerMove);
document.addEventListener("pointerup", this.boundPointerUp);
e.preventDefault();
Logger.debug(`[DragValue] Started drag on ${this.config.widgetName}`);
}
/**
* Handle pointer move - update value based on drag distance
*/
onPointerMove(e) {
if (!this.isDragging) return;
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);
}
}
/**
* Handle number value drag
*/
handleNumberDrag(deltaX) {
var _a, _b;
const constraints = this.config.constraints;
const isInt = this.config.widgetType.toLowerCase() === "int";
let sensitivity;
if (isInt) {
sensitivity = DRAG_SENSITIVITY.intPixelsPerUnit;
} else {
const step2 = (constraints == null ? void 0 : constraints.step) ?? 0.1;
sensitivity = DRAG_SENSITIVITY.floatPixelsPerTenth / (step2 / 0.1);
}
const units = deltaX / sensitivity;
const step = (constraints == null ? void 0 : constraints.step) ?? (isInt ? 1 : 0.1);
let newValue = this.startValue + units * 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 (step) {
newValue = Math.round(newValue / step) * step;
}
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);
}
}
/**
* Handle pointer up - end drag
*/
onPointerUp(e) {
if (!this.isDragging) return;
this.isDragging = false;
this.element.classList.remove("dragging");
try {
this.element.releasePointerCapture(e.pointerId);
} catch {
}
document.removeEventListener("pointermove", this.boundPointerMove);
document.removeEventListener("pointerup", this.boundPointerUp);
Logger.debug(`[DragValue] Ended drag on ${this.config.widgetName}`);
}
/**
* 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();
}
document.removeEventListener("pointermove", this.boundPointerMove);
document.removeEventListener("pointerup", this.boundPointerUp);
}
/**
* Check if a widget type supports drag editing
*/
static isTypeSupported(widgetType) {
const type = widgetType.toLowerCase();
return [
"number",
"int",
"float",
"slider",
"combo"
].includes(type);
}
}
export {
DragValueController
};
//# sourceMappingURL=DragValueController.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,106 @@
import { WidgetSyncManager } from "./WidgetSyncManager.js";
import { Logger } from "../../shared/logger.js";
class InlineControlFactory {
/**
* Check if a widget type should use inline controls
*/
static shouldUseInlineControl(widgetType) {
const type = widgetType.toLowerCase();
return type === "toggle" || type === "boolean" || type === "combo";
}
/**
* Create an inline control for the given configuration
*/
static createControl(config) {
const type = config.widgetType.toLowerCase();
switch (type) {
case "toggle":
case "boolean":
return this.createInlineToggle(config);
case "combo":
return this.createInlineDropdown(config);
default:
return null;
}
}
/**
* Create compact inline toggle for boolean values
*/
static createInlineToggle(config) {
const container = document.createElement("div");
container.className = "inline-control inline-toggle";
const label = document.createElement("label");
label.className = "inline-toggle-switch";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "inline-toggle-input";
const boolValue = config.currentValue === true || config.currentValue === "true";
checkbox.checked = boolValue;
const slider = document.createElement("span");
slider.className = "inline-toggle-slider";
container.addEventListener("click", (e) => e.stopPropagation());
checkbox.addEventListener("change", () => {
var _a;
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, checkbox.checked);
(_a = config.onChange) == null ? void 0 : _a.call(config, checkbox.checked);
Logger.debug(`[InlineControl] Toggle ${config.widgetName}: ${checkbox.checked}`);
});
label.appendChild(checkbox);
label.appendChild(slider);
container.appendChild(label);
return {
element: container,
getValue: () => checkbox.checked,
setValue: (v) => {
checkbox.checked = Boolean(v);
},
destroy: () => {
container.remove();
}
};
}
/**
* Create compact inline dropdown for combo values
*/
static createInlineDropdown(config) {
var _a;
const container = document.createElement("div");
container.className = "inline-control inline-dropdown";
const select = document.createElement("select");
select.className = "inline-dropdown-select";
const options = ((_a = config.constraints) == null ? void 0 : _a.options) ?? [];
const currentValueStr = String(config.currentValue);
for (const opt of options) {
const option = document.createElement("option");
const optStr = String(opt);
option.value = optStr;
option.textContent = optStr;
if (optStr === currentValueStr) {
option.selected = true;
}
select.appendChild(option);
}
container.addEventListener("click", (e) => e.stopPropagation());
select.addEventListener("change", () => {
var _a2;
WidgetSyncManager.syncWidgetValue(config.nodeId, config.widgetName, select.value);
(_a2 = config.onChange) == null ? void 0 : _a2.call(config, select.value);
Logger.debug(`[InlineControl] Dropdown ${config.widgetName}: ${select.value}`);
});
container.appendChild(select);
return {
element: container,
getValue: () => select.value,
setValue: (v) => {
select.value = String(v);
},
destroy: () => {
container.remove();
}
};
}
}
export {
InlineControlFactory
};
//# sourceMappingURL=InlineControlFactory.js.map
File diff suppressed because one or more lines are too long
@@ -100,7 +100,8 @@ class WidgetEditorFactory {
syncValue();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (e.key === "Escape" || e.key === "Enter") {
e.preventDefault();
input.blur();
}
});
@@ -231,7 +232,8 @@ class WidgetEditorFactory {
return (_a2 = config.onBlur) == null ? void 0 : _a2.call(config);
});
select.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (e.key === "Escape" || e.key === "Enter") {
e.preventDefault();
select.blur();
}
});
File diff suppressed because one or more lines are too long