import { app } from "../../scripts/app.js"; const MENU_SELECTORS = [".comfyui-menu", ".comfy-menu", '[data-testid="comfy-menu"]']; const CANVAS_SELECTORS = ["#graph-canvas", "canvas#graph-canvas", "canvas.litegraph", '[data-testid="graph-canvas"]']; const icons = { bottom: ``, centerX: ``, centerY: ``, left: ``, right: ``, top: ``, equalW: ``, equalH: ``, distX: ``, distY: `` }; const NodeAlignFull = { initialized: false, toolbar: null, menu: null, canvasEl: null, insertionIndicator: null, dragState: { active: false, offsetX: 0, offsetY: 0 }, position: { leftPct: 50, topPct: 5, attached: false, insertIndex: 0 }, rememberPosition: true, displayRaf: null, async init() { if (this.initialized) return; await this.waitForDom(); this.logCompat(); this.loadPosition(); this.injectStyles(); this.buildToolbar(); this.applyLayoutSettings(); this.restorePosition(); this.bindCanvasEvents(); this.bindShortcuts(); this.initialized = true; this.updateDisplay(); }, waitForDom() { return new Promise(resolve => { const tick = () => { this.menu = MENU_SELECTORS.map(sel => document.querySelector(sel)).find(Boolean); this.canvasEl = app.canvas?.canvas || CANVAS_SELECTORS.map(sel => document.querySelector(sel)).find(Boolean); if (this.menu && this.canvasEl) return resolve(); requestAnimationFrame(tick); }; tick(); }); }, getSetting(key, fallback) { const v = app.ui?.settings?.getSettingValue?.(key); return v ?? fallback; }, loadPosition() { this.rememberPosition = this.getSetting("NodeAlign.RememberPosition", true); const saved = this.rememberPosition ? JSON.parse(localStorage.getItem("NodeAlignFullPosition") || "{}") : {}; this.position.leftPct = saved.leftPct ?? 50; this.position.topPct = saved.topPct ?? 5; this.position.attached = saved.attached ?? false; this.position.insertIndex = saved.insertIndex ?? 0; if (!this.rememberPosition) localStorage.removeItem("NodeAlignFullPosition"); }, savePosition() { if (!this.rememberPosition) return; localStorage.setItem("NodeAlignFullPosition", JSON.stringify(this.position)); }, injectStyles() { if (document.getElementById("nodealign-full-style")) return; const dividerColor = this.getSetting("NodeAlign.NodeAlignDividerColor", "666"); const style = document.createElement("style"); style.id = "nodealign-full-style"; style.textContent = ` #nodealign-full-toolbar{display:flex;align-items:center;gap:4px;padding:4px;border-radius:4px;z-index:10000;white-space:nowrap;user-select:none;pointer-events:auto;flex-wrap:nowrap;} #nodealign-full-toolbar.floating{position:fixed;} #nodealign-full-toolbar.attached{position:relative;margin-left:10px;} .nodealign-btn{width:var(--nodealign-btn-size,25px);height:var(--nodealign-btn-size,25px);display:flex;align-items:center;justify-content:center;background-color:var(--nodealign-icon-bg);border:none;cursor:pointer;padding:0;border-radius:4px;transition:background-color .2s ease,transform .1s ease;flex-shrink:0;position:relative;overflow:hidden;} .nodealign-btn svg{width:66%;height:66%;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);} .nodealign-divider{width:var(--nodealign-divider-w,3px);height:var(--nodealign-divider-h,15px);background:#${dividerColor};border-radius:9px;cursor:grab;flex-shrink:0;} .nodealign-divider:active{cursor:grabbing;} .comfy-menu,.comfyui-menu{position:relative;display:flex;align-items:center;} `; document.head.appendChild(style); }, buildToolbar() { if (document.getElementById("nodealign-full-toolbar")) return; const bg = this.getSetting("NodeAlign.NodeAlignBackgroundColor", "1b1b1b"); const opacity = this.getSetting("NodeAlign.NodeAlignBackgroundOpacity", 70) / 100; const iconBg = this.getSetting("NodeAlign.NodeAlignIconBackgroundColor", "2a2a2a"); const iconColor = this.getSetting("NodeAlign.NodeAlignIconColor", "e0e0e0"); this.toolbar = document.createElement("div"); this.toolbar.id = "nodealign-full-toolbar"; this.toolbar.classList.add(this.position.attached ? "attached" : "floating"); this.toolbar.style.setProperty("--nodealign-icon-bg", `#${iconBg}`); this.toolbar.style.background = `rgba(${parseInt(bg.slice(0,2),16)}, ${parseInt(bg.slice(2,4),16)}, ${parseInt(bg.slice(4,6),16)}, ${opacity})`; const addBtn = (id, icon, action, title) => { const el = document.createElement("button"); el.className = "nodealign-btn"; el.id = id; el.title = title || ""; el.innerHTML = icon.replace(/#666/g, `#${iconColor}`); el.addEventListener("click", action); el.addEventListener("mouseover", () => el.style.backgroundColor = this.adjustColor(iconBg, 60)); el.addEventListener("mouseout", () => el.style.backgroundColor = `#${iconBg}`); el.addEventListener("mousedown", () => el.style.transform = "scale(0.95)"); el.addEventListener("mouseup", () => el.style.transform = ""); el.addEventListener("mouseleave", () => el.style.transform = ""); this.toolbar.appendChild(el); }; const addDivider = () => { const div = document.createElement("div"); div.className = "nodealign-divider"; div.addEventListener("mousedown", e => this.onDragStart(e)); div.addEventListener("dblclick", () => this.resetPosition(true)); this.toolbar.appendChild(div); }; addBtn("align-left", icons.left, () => this.align("left"), "Align Left"); addBtn("align-centerY", icons.centerY, () => this.align("centerY"), "Align Center Vertically"); addBtn("align-right", icons.right, () => this.align("right"), "Align Right"); addDivider(); addBtn("align-top", icons.top, () => this.align("top"), "Align Top"); addBtn("align-centerX", icons.centerX, () => this.align("centerX"), "Align Center Horizontally"); addBtn("align-bottom", icons.bottom, () => this.align("bottom"), "Align Bottom"); addDivider(); addBtn("equal-w", icons.equalW, () => this.equal("w"), "Equal Width"); addBtn("equal-h", icons.equalH, () => this.equal("h"), "Equal Height"); addDivider(); addBtn("dist-x", icons.distX, () => this.distribute("x"), "Horizontal Distribution"); addBtn("dist-y", icons.distY, () => this.distribute("y"), "Vertical Distribution"); this.insertionIndicator = document.createElement("div"); this.insertionIndicator.id = "nodealign-insert-indicator"; this.menu.appendChild(this.insertionIndicator); this.toolbar.addEventListener("dblclick", () => this.resetPosition(false)); if (this.position.attached) { const children = Array.from(this.menu.children).filter(c => c !== this.insertionIndicator); const idx = Math.min(this.position.insertIndex, children.length); this.menu.insertBefore(this.toolbar, children[idx] || null); } else { document.body.appendChild(this.toolbar); } document.addEventListener("mousemove", e => this.onDragging(e)); document.addEventListener("mouseup", () => this.onDragEnd()); document.addEventListener("selectstart", e => this.dragState.active && e.preventDefault()); }, adjustColor(hex, delta) { const c = hex.padEnd(6, "0"); const r = parseInt(c.slice(0,2),16); const g = parseInt(c.slice(2,4),16); const b = parseInt(c.slice(4,6),16); const bright = Math.round(0.299*r + 0.587*g + 0.114*b); const adj = bright > 127 ? -delta : delta; return `#${[r,g,b].map(v => Math.max(0, Math.min(255, v+adj)).toString(16).padStart(2,"0")).join("")}`; }, getSelectedNodes() { const pools = [app.canvas?.selected_nodes, app.graph?.canvas?.selected_nodes, app.graph?.selected_nodes]; const out = []; for (const pool of pools) { if (!pool) continue; if (pool instanceof Map || pool instanceof Set) out.push(...pool.values()); else if (Array.isArray(pool)) out.push(...pool); else if (typeof pool === "object") out.push(...Object.values(pool)); } return out.filter(n => n && n.pos && n.size); }, align(mode) { const nodes = this.getSelectedNodes(); if (nodes.length < 2) return; if (mode === "left") { const minX = Math.min(...nodes.map(n => n.pos[0])); nodes.forEach(n => n.pos[0] = minX); } else if (mode === "right") { const maxX = Math.max(...nodes.map(n => n.pos[0] + n.size[0])); nodes.forEach(n => n.pos[0] = maxX - n.size[0]); } else if (mode === "top") { const minY = Math.min(...nodes.map(n => n.pos[1])); nodes.forEach(n => n.pos[1] = minY); } else if (mode === "bottom") { const maxY = Math.max(...nodes.map(n => n.pos[1] + n.size[1])); nodes.forEach(n => n.pos[1] = maxY - n.size[1]); } else if (mode === "centerX") { const minY = Math.min(...nodes.map(n => n.pos[1])); const maxY = Math.max(...nodes.map(n => n.pos[1] + n.size[1])); const c = (minY + maxY) / 2; nodes.forEach(n => n.pos[1] = c - n.size[1] / 2); } else if (mode === "centerY") { const minX = Math.min(...nodes.map(n => n.pos[0])); const maxX = Math.max(...nodes.map(n => n.pos[0] + n.size[0])); const c = (minX + maxX) / 2; nodes.forEach(n => n.pos[0] = c - n.size[0] / 2); } this.redraw(); }, equal(axis) { const nodes = this.getSelectedNodes(); if (nodes.length < 2) return; if (axis === "w") { const w = nodes[0].size[0]; nodes.forEach(n => n.size[0] = w); } else { const h = nodes[0].size[1]; nodes.forEach(n => n.size[1] = h); } this.redraw(); }, distribute(axis) { const nodes = this.getSelectedNodes(); if (nodes.length < 2) { console.info("[NodeAlign] Distribution skipped: need 2+ nodes"); return; } const idx = axis === "x" ? 0 : 1; const sorted = [...nodes].sort((a, b) => a.pos[idx] - b.pos[idx]); const min = Math.min(...sorted.map(n => n.pos[idx])); const max = Math.max(...sorted.map(n => n.pos[idx] + n.size[idx])); const total = sorted.reduce((s, n) => s + n.size[idx], 0); const gap = (max - min - total) / Math.max(1, sorted.length - 1); let cursor = min; sorted.forEach(n => { n.pos[idx] = cursor; cursor += n.size[idx] + gap; }); console.info("[NodeAlign] Distribution", { axis, nodes: sorted.length, gap }); this.redraw(); }, redraw() { if (app.canvas?.setDirty) app.canvas.setDirty(true, true); if (app.graph?.setDirtyCanvas) app.graph.setDirtyCanvas(true); }, updateDisplay() { if (!this.toolbar) return; const mode = this.getSetting("NodeAlign.NodeAlignDisplayMode", "permanent"); const selected = this.getSelectedNodes().length; if (mode === "disabled") this.toolbar.style.display = "none"; else if (mode === "on-select" && selected < 2) this.toolbar.style.display = "none"; else this.toolbar.style.display = "flex"; }, queueDisplayUpdate() { if (this.displayRaf) return; this.displayRaf = requestAnimationFrame(() => { this.displayRaf = null; this.updateDisplay(); }); }, onDragStart(e) { e.preventDefault(); const rect = this.toolbar.getBoundingClientRect(); this.dragState.active = true; this.dragState.offsetX = e.clientX - rect.left; this.dragState.offsetY = e.clientY - rect.top; }, onDragging(e) { if (!this.dragState.active) return; const rect = this.toolbar.getBoundingClientRect(); const menuRect = this.menu.getBoundingClientRect(); const winW = window.innerWidth; const winH = window.innerHeight; if (this.position.attached) this.detach(true); let left = e.clientX - this.dragState.offsetX; let top = e.clientY - this.dragState.offsetY; left = Math.max(0, Math.min(left, winW - rect.width)); top = Math.max(0, Math.min(top, winH - rect.height)); this.toolbar.style.left = `${left}px`; this.toolbar.style.top = `${top}px`; const tbBottom = rect.top + rect.height; if (tbBottom > menuRect.top && rect.top < menuRect.bottom) { this.insertionIndicator.style.display = "block"; const children = Array.from(this.menu.children).filter(c => c !== this.insertionIndicator && c !== this.toolbar); const tbCenter = rect.left + rect.width / 2; let indicatorLeft = 0; for (let i = 0; i < children.length; i++) { const childRect = children[i].getBoundingClientRect(); if (tbCenter < childRect.left + childRect.width / 2) { indicatorLeft = childRect.left - menuRect.left; break; } indicatorLeft = children[children.length - 1]?.getBoundingClientRect().right - menuRect.left || 0; } this.insertionIndicator.style.left = `${Math.max(0, Math.min(indicatorLeft, menuRect.width - this.insertionIndicator.offsetWidth))}px`; } else { this.insertionIndicator.style.display = "none"; } }, onDragEnd() { if (!this.dragState.active) return; this.dragState.active = false; const rect = this.toolbar.getBoundingClientRect(); const menuRect = this.menu.getBoundingClientRect(); const inMenu = rect.top + rect.height > menuRect.top && rect.top < menuRect.bottom; if (inMenu) { const children = Array.from(this.menu.children).filter(c => c !== this.insertionIndicator && c !== this.toolbar); const tbCenter = rect.left + rect.width / 2; let idx = 0; for (let i = 0; i < children.length; i++) { const childRect = children[i].getBoundingClientRect(); if (tbCenter < childRect.left + childRect.width / 2) { idx = i; break; } idx = i + 1; } this.attach(children[idx]); this.position.attached = true; this.position.insertIndex = idx; } else { this.position.leftPct = ((rect.left + rect.width / 2) / window.innerWidth) * 100; this.position.topPct = (rect.top / window.innerHeight) * 100; this.position.attached = false; this.position.insertIndex = 0; this.updateFloatingPosition(); } this.savePosition(); this.insertionIndicator.style.display = "none"; }, resetPosition(fromDivider) { this.position = { leftPct: 50, topPct: 5, attached: false, insertIndex: 0 }; this.detach(true); this.savePosition(); }, attach(target) { this.position.attached = true; this.toolbar.classList.remove("floating"); this.toolbar.classList.add("attached"); this.toolbar.style.left = ""; this.toolbar.style.top = ""; this.menu.insertBefore(this.toolbar, target || null); }, detach(isDrag) { if (!isDrag) return; this.position.attached = false; this.toolbar.classList.remove("attached"); this.toolbar.classList.add("floating"); document.body.appendChild(this.toolbar); this.updateFloatingPosition(); }, updateFloatingPosition() { if (!this.toolbar || this.position.attached) return; const rect = this.toolbar.getBoundingClientRect(); const left = (this.position.leftPct / 100) * window.innerWidth - rect.width / 2; const top = (this.position.topPct / 100) * window.innerHeight; this.toolbar.style.left = `${Math.max(0, Math.min(left, window.innerWidth - rect.width))}px`; this.toolbar.style.top = `${Math.max(0, Math.min(top, window.innerHeight - rect.height))}px`; }, restorePosition() { if (this.position.attached && this.menu && this.toolbar) { const children = Array.from(this.menu.children).filter(c => c !== this.insertionIndicator && c !== this.toolbar); const idx = Math.min(this.position.insertIndex, children.length); this.attach(children[idx]); } else { this.position.attached = false; this.updateFloatingPosition(); } }, bindCanvasEvents() { const refresh = () => this.queueDisplayUpdate(); this.canvasEl?.addEventListener("pointerup", refresh); this.canvasEl?.addEventListener("click", refresh); }, bindShortcuts() { document.addEventListener("keydown", e => { if (!e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; const enable = this.getSetting("NodeAlign.EnableAlignmentShortcuts", true); if (!enable || this.getSelectedNodes().length < 2) return; const key = e.key.toLowerCase(); if (["w","a","s","d"].includes(key)) e.preventDefault(); if (key === "w") this.align("top"); if (key === "a") this.align("left"); if (key === "s") this.align("bottom"); if (key === "d") this.align("right"); }); }, logCompat() { const menuSel = MENU_SELECTORS.find(sel => document.querySelector(sel)); const canvasSel = CANVAS_SELECTORS.find(sel => document.querySelector(sel)); console.info("[NodeAlign] init", { menuSelector: menuSel, canvasSelector: canvasSel, rememberPosition: this.getSetting("NodeAlign.RememberPosition", true) }); }, applyLayoutSettings() { if (!this.toolbar) return; const size = Number(this.getSetting("NodeAlign.ButtonSize", 25)) || 25; const dividerH = Math.round(size * 0.6); const dividerW = Math.max(2, Math.round(size * 0.13)); const finalOrientation = "horizontal"; this.toolbar.style.setProperty("--nodealign-btn-size", `${size}px`); this.toolbar.style.setProperty("--nodealign-divider-h", `${dividerH}px`); this.toolbar.style.setProperty("--nodealign-divider-w", `${dividerW}px`); console.info("[NodeAlign] layout", { size, orientation: finalOrientation }); } }; app.registerExtension({ name: "NodeAlign.NodeAlignFull", init() { NodeAlignFull.init(); window.NodeAlignFull = NodeAlignFull; } });