import { app } from "../../scripts/app.js"; // --- Dropdown UI Component --- let activeDropdown = null; class Dropdown { constructor(inputEl, options, onSelect, manualOffset, hostElement) { this.inputEl = inputEl; this.options = options; this.onSelect = onSelect; this.manualOffset = manualOffset; this.hostElement = hostElement; this.dropdown = document.createElement('ul'); this.dropdown.className = 'dadashuai-dropdown'; this.dropdown.setAttribute('role', 'listbox'); // [Opt] 设置基础 Z-Index this.dropdown.style.zIndex = 10001; this.selectedIndex = -1; this.focusedDropdown = this.dropdown; this.openedAt = Date.now(); this.buildDropdown(); this.updatePosition(); // Bind events this.handleKeyDown = this.onKeyDown.bind(this); this.handleWheel = this.onWheel.bind(this); this.handleClick = this.onClick.bind(this); document.addEventListener('keydown', this.handleKeyDown); this.dropdown.addEventListener('wheel', this.handleWheel); document.addEventListener('click', this.handleClick); activeDropdown = this; } buildDropdown() { this.buildNested(this.options, this.dropdown); this.hostElement.appendChild(this.dropdown); } buildNested(dict, parent, path = '') { let index = 0; // 计算当前菜单层级,用于关闭更深层的菜单 const currentDepth = (path.split('/').filter(Boolean).length || 0) + 1; for (const [key, val] of Object.entries(dict)) { // [Fix] 这里的 idx 必须用 const 锁定当前循环的值 const idx = index; const extra = typeof val === 'string' ? val : ''; let fullPath = path ? `${path}/${key}` : key; if (extra) fullPath += `###${extra}`; const li = document.createElement('li'); if (val && typeof val === "object") { li.className = 'folder'; li.textContent = key; const subUl = document.createElement('ul'); subUl.className = 'dadashuai-nested-dropdown'; subUl.dataset.depth = currentDepth + 1; // [Opt] 动态增加 Z-Index,确保子菜单永远覆盖父菜单 subUl.style.zIndex = 10001 + currentDepth + 1; li.addEventListener('mouseenter', () => { this.hideNested(subUl.dataset.depth, subUl); this.positionNested(li, subUl); }); li.addEventListener('mouseover', () => this.setFocus(idx, parent)); // Prevent closing when clicking nested scrollbar subUl.addEventListener('mousedown', e => e.stopPropagation()); subUl.addEventListener('click', e => e.stopPropagation()); this.hostElement.appendChild(subUl); this.buildNested(val, subUl, fullPath); } else { li.className = 'item'; li.setAttribute('role', 'option'); li.textContent = key; // 鼠标滑过普通项时,关闭同级的子菜单 li.addEventListener('mouseenter', () => { this.hideNested(currentDepth + 1, null); }); li.addEventListener('mouseover', () => this.setFocus(idx, parent)); li.addEventListener('mousedown', (e) => { e.preventDefault(); this.onSelect(key, fullPath); }); } parent.appendChild(li); index++; } } updatePosition() { const rect = this.inputEl.getBoundingClientRect(); const parse = (v, dim) => isNaN(v) && String(v).includes('%') ? dim * (parseInt(v)/100) : v; const offX = parse(this.manualOffset[0], rect.height); const offY = parse(this.manualOffset[1], rect.width); const winW = window.innerWidth; const winH = window.innerHeight; const margin = 10; // JS 中的 maxWidth 主要是为了初始计算,CSS 中的 max-width 才是强制生效的关键 this.dropdown.style.maxWidth = `${Math.min(450, winW - 20)}px`; // Initial placement let top = rect.bottom - offX; let left = rect.right - offY; this.dropdown.style.top = `${top}px`; this.dropdown.style.left = `${left}px`; // Constraint to viewport const dropRect = this.dropdown.getBoundingClientRect(); const newLeft = Math.max(margin, Math.min(dropRect.left, winW - dropRect.width - margin)); const newTop = Math.max(margin, Math.min(dropRect.top, winH - dropRect.height - margin)); this.dropdown.style.left = `${newLeft}px`; this.dropdown.style.top = `${newTop}px`; } positionNested(parentLi, subUl) { subUl.style.display = 'block'; const pRect = parentLi.getBoundingClientRect(); const sRect = subUl.getBoundingClientRect(); const margin = 10; let left = pRect.right; if (left + sRect.width > window.innerWidth - margin) { left = pRect.left - sRect.width; } left = Math.max(margin, left); let top = pRect.top; if (top + sRect.height > window.innerHeight - margin) { top = Math.max(margin, window.innerHeight - sRect.height - margin); } subUl.style.top = `${top}px`; subUl.style.left = `${left}px`; } hideNested(depth, except) { document.querySelectorAll('.dadashuai-nested-dropdown').forEach(el => { if (Number(el.dataset.depth || 0) >= depth && el !== except) { el.style.display = 'none'; } }); } setFocus(index, parent) { if (parent) this.focusedDropdown = parent; this.selectedIndex = index; this.renderSelection(); } renderSelection() { const items = Array.from(this.focusedDropdown.children); items.forEach((li, i) => li.classList.toggle('selected', i === this.selectedIndex)); } destroy() { document.removeEventListener('keydown', this.handleKeyDown); this.dropdown.removeEventListener('wheel', this.handleWheel); document.removeEventListener('click', this.handleClick); document.querySelectorAll('.dadashuai-dropdown, .dadashuai-nested-dropdown').forEach(el => el.remove()); activeDropdown = null; } onKeyDown(e) { if (!activeDropdown) return; const items = Array.from(this.focusedDropdown.children); const selected = items[this.selectedIndex]; switch(e.key) { case 'ArrowUp': e.preventDefault(); this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.renderSelection(); break; case 'ArrowDown': e.preventDefault(); this.selectedIndex = Math.min(items.length - 1, this.selectedIndex + 1); this.renderSelection(); break; case 'ArrowLeft': if (this.focusedDropdown !== this.dropdown) { // Logic to go back up level could go here, simplified to just preventing default } break; case 'Enter': case 'Tab': if (this.selectedIndex >= 0 && selected?.classList.contains('item')) { e.preventDefault(); // Note: Keyboard selection requires tracking fullPath which isn't stored in DOM in simplified version // For now, click is primary. selected.dispatchEvent(new MouseEvent('mousedown')); } break; case 'Escape': this.destroy(); break; } } onWheel(e) { const currentTop = parseInt(this.dropdown.style.top); const delta = e.deltaY < 0 ? 10 : -10; const invert = localStorage.getItem("Comfy.Settings.Comfy.InvertMenuScrolling"); this.dropdown.style.top = `${currentTop + (invert ? -delta : delta)}px`; } onClick(e) { if (Date.now() - this.openedAt < 200) return; if (!e.target.closest('.dadashuai-dropdown, .dadashuai-nested-dropdown') && e.target !== this.inputEl) { this.destroy(); } } } // --- Data & Logic Helpers --- const IGNORE_WIDGETS = new Set(['control_after_generate', 'empty_latent_aspect', 'empty_latent_width', 'empty_latent_height', 'batch_size']); function getNodeOptions(node) { if (!node.widgets) return null; const opts = {}; for (const w of node.widgets) { if (!w.type || IGNORE_WIDGETS.has(w.name)) continue; const val = w.value; // Handle Seeds if (w.name === 'seed' || (w.name === 'value' && node.constructor.title?.toLowerCase() === 'seed')) { opts[w.name] = { [val ?? 'seed']: null }; continue; } // Handle Toggles if (w.type === 'toggle') { opts[w.name] = { 'True': null, 'False': null }; continue; } // Handle Text/Number inputs if (['customtext', 'text', 'string', 'number'].includes(w.type) || w.type.startsWith('dadashuai')) { const safeVal = (val !== undefined && val !== null && String(val).trim() !== '') ? val : 'value'; opts[w.name] = { [safeVal]: null }; continue; } // Handle Lists (Combo) if (w.options?.values) { let values = w.options.values; if (typeof values === 'function') values = values(); const valDict = {}; for (const v of values) valDict[v] = null; if (Object.keys(valDict).length > 0) opts[w.name] = valDict; } } return Object.keys(opts).length ? opts : null; } // [Fix] 增加参数 ignoreId 用于排除自身节点 function getGraphOptions(ignoreId) { const dict = {}; for (const node of app.graph._nodes) { // [Fix] 排除当前节点自身 if (node.id === ignoreId) continue; // [Fix] 排除没有输出端口的节点 (例如 Note 节点或纯输入节点) if (!node.outputs || node.outputs.length === 0) continue; const opts = getNodeOptions(node); if (opts) { const title = node.title || node.constructor?.title || node.type; dict[`[${node.id}] - ${title}`] = opts; } } return dict; } function insertValue(widget, fullpath, selectedVal) { // Parse path: "Node/Widget/Value" const [rawPath] = fullpath.split('###'); const parts = rawPath.split('/'); const nodeKey = parts[0] || ''; const widgetName = parts[1] || ''; let value = parts[2] ?? selectedVal; // Format value if (typeof value === 'boolean') value = value ? 'True' : 'False'; const strVal = String(value); const finalVal = (strVal === 'True' || strVal === 'False' || (!isNaN(Number(strVal)) && strVal.trim() !== '')) ? strVal : `'${strVal.replace(/^['"]|['"]$/g, '')}'`; // Simple quote clean // Extract ID const nodeIdMatch = nodeKey.match(/\[(\d+)\]/); const nodeId = nodeIdMatch ? nodeIdMatch[1] : nodeKey; // Construct Line const line = `[${nodeId}:${widgetName}]=${finalVal}`; // Update Input const current = widget.inputEl.value || ''; // Auto-increment index const matches = current.match(/^<(\d+):/gm); let nextIdx = 1; if (matches) { const indices = matches.map(s => parseInt(s.substring(1))); nextIdx = Math.max(...indices) + 1; } // [Change] 自动生成标签: X1, X2... 或 Y1, Y2... const axisChar = widget.name === 'y_attr' ? 'Y' : 'X'; const block = `<${nextIdx}:${axisChar}${nextIdx}>\n${line}`; widget.inputEl.value = current.trim() ? `${current.replace(/\s+$/,'')}\n${block}` : block; // Trigger update const ev = new Event('input', { bubbles: true }); widget.inputEl.dispatchEvent(ev); } function injectStyles() { if (document.getElementById('dadashuai-style')) return; const style = document.createElement('style'); style.id = 'dadashuai-style'; style.textContent = ` .dadashuai-dropdown, .dadashuai-nested-dropdown { position: fixed; background: #1c1c1c; color: #e8e8e8; border: 1px solid #363636; border-radius: 6px; padding: 0; /* [Fix] 移除容器内边距,消除顶部底部空隙 */ list-style: none; max-height: 320px; min-width: 200px; max-width: 450px; /* [Fix] 限制最大宽度,强制长内容换行 */ overflow-y: auto; z-index: 9999; font-size: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); } /* [Fix] 将样式规则同时应用到 .dadashuai-nested-dropdown li */ .dadashuai-dropdown li, .dadashuai-nested-dropdown li { padding: 5px 12px; cursor: pointer; position: relative; /* [Fix] 强制换行策略:优先在单词间换行,必要时打断单词 */ white-space: normal; word-wrap: break-word; overflow-wrap: break-word; line-height: 1.4; border-bottom: 1px solid rgba(255,255,255,0.05); } /* [Fix] 移除最后一个子元素的底边框 */ .dadashuai-dropdown li:last-child, .dadashuai-nested-dropdown li:last-child { border-bottom: none; } .dadashuai-dropdown li.selected, .dadashuai-nested-dropdown li.selected { background: #4a90e2; color: #fff; } .dadashuai-dropdown li.folder, .dadashuai-nested-dropdown li.folder { font-weight: 600; } .dadashuai-dropdown li.folder::after, .dadashuai-nested-dropdown li.folder::after { content: '›'; float: right; opacity: 0.6; } /* [Fix] 嵌套菜单必须是 fixed 定位以配合 getBoundingClientRect 计算 */ .dadashuai-nested-dropdown { display: none; position: fixed; } `; document.head.appendChild(style); } function attachDropdown(node) { if (!node.widgets) return; injectStyles(); const targets = node.widgets.filter(w => ['x_attr', 'y_attr'].includes(w.name)); for (const w of targets) { const handler = (e) => { // Only show if widget has input element and data exists if (!w.inputEl) return; // [Fix] 传递当前节点的 ID,以便在获取选项时排除自身 const data = getGraphOptions(node.id); if (!data || Object.keys(data).length === 0) return; if (activeDropdown) activeDropdown.destroy(); // [Change] 如果是右键菜单事件,阻止浏览器默认菜单 if (e.type === 'contextmenu') { e.preventDefault(); } // Create new dropdown new Dropdown(w.inputEl, data, (val, path) => insertValue(w, path, val), [10, '100%'], document.body); }; // Retry attachment if DOM not ready const tryAttach = () => { if (!w.inputEl) return false; // [Change] 监听右键 (contextmenu) 而不是单击 (click) 或 聚焦 (focus) w.inputEl.addEventListener('contextmenu', handler); return true; }; if (!tryAttach()) { const timer = setInterval(() => { if (tryAttach()) clearInterval(timer); }, 100); } } } app.registerExtension({ name: "comfy.xyd.dropdown", beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name === "XY_Image" && nodeData.widgets) { nodeData.widgets.forEach(w => { if (['x_attr', 'y_attr'].includes(w.name)) { w.type = 'customtext'; w.options = { ...w.options, multiline: true }; } }); } }, nodeCreated(node) { if (node.constructor.type === "XY_Image" || node.title === "XY_Image") { attachDropdown(node); } } });