- Add Notes/Description tab switching with tab state persistence in widget value - Lazy-load model description and version description from /lm/loras/metadata - Render CivitAI HTML descriptions inline via v-html - Auto-fetch description when LoRA selection changes while on Description tab - Fix Vue mode height containment via contain:layout size (lm-vue-node class) - Fix scroll wheel isolation: widget scroll vs canvas zoom in both render modes - Add docs/comfyui-dual-mode-widgets.md with widget rendering patterns
3.1 KiB
ComfyUI Dual-Mode Widget Rendering
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
Mode Detection
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
In Vue SFCs, window.LiteGraph is unavailable — pass as a prop from main.ts.
Canvas Mode Layout
Uses computeLayoutSize() + distributeSpace() to allocate widget height within the node. Widgets with computeLayoutSize participate in space distribution; those with computeSize have fixed height.
getMinHeight()inaddDOMWidgetoptions → minimum widget heightwidget.computeLayoutSize()→{ minHeight, minWidth, maxHeight? }- Avoid
getMaxHeight()unless the widget genuinely needs a fixed cap (prevents user resize)
Vue Mode Layout
Uses CSS Grid (grid-template-rows) + ResizeObserver. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
Height Containment
The fix: contain: layout size on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
.widget-root.lm-vue-node {
height: 100%;
min-height: var(--comfy-widget-min-height, 200px);
contain: layout size;
}
Existing examples: .lm-loras-container.lm-vue-node and .comfy-tags-container.lm-vue-node in web/comfyui/lm_styles.css.
Do NOT fix height issues with maxHeight, getMaxHeight(), or inline max-height — these prevent the user from resizing the node.
Scroll Wheel Isolation
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
Canvas mode: Add @wheel on widget root. Check event.target.closest(selector) for scrollable sub-areas. If scrollable → event.stopPropagation(). Otherwise → app.canvas.processMouseWheel(event).
Vue mode: Add CSS class lm-wheel-scrollable to scrollable elements. The global capture-phase hook in web/comfyui/utils.js (enableListWheelScroll) detects wheel events on marked elements and manually scrolls them via element.scrollTop, consuming the event before canvas zoom sees it.
DOM Structure
main.ts creates an outer <div> container, then vueApp.mount(container). The Vue app renders its own root element inside.
container.id/container.style.*→ outer element- Vue scoped
<style>→[data-v-hash]applies only to Vue root
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with :class rather than manipulating the DOM from main.ts.
Serialization
For stateful widgets that need workflow persistence:
serialize: trueinaddDOMWidgetoptionsserializeValue()→ state snapshot (called on workflow save)onSetValue(v)→ restore state (called on workflow load)- Always handle missing keys in restored value for backward compatibility with old workflows