Files
Hasasasa-ComfyUI_DashuaiTools/web/XY_Image.js
T

436 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <N:name>
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);
}
}
});