Files
kk8bit-KayTool/web/nodealign.js
T
kk8bit 61a9bc89d2 Rebuild the node align toolbar as an independent docking panel
waitForElements() polled for .comfyui-menu, which the current frontend no
longer renders, so its promise never resolved. init() was stuck on that
await forever: the toolbar was never built and the Shift+WASD shortcuts
were never bound, while the requestAnimationFrame poll kept spinning for
the life of the session.

Docking into the DOM is no longer viable either way. The topbar is Vue
managed, so a card injected next to the Run button is wiped on the next
re-render, and the supported legacy slot (app.menu.element) lives inside
the Run button's own card, which would drag our toolbar around with it.

So the toolbar is now always a fixed element on document.body, like the
resource monitor, and docking is positional rather than structural: drop
it anywhere over the top bar row and it aligns to that row's centre,
keeping the x the user chose and stepping aside from the Run card. The
band geometry is read through data-testid hooks with a plain fallback, so
a future frontend redesign degrades to floating instead of vanishing.
2026-09-20 17:48:36 +08:00

651 lines
32 KiB
JavaScript

import { app } from "../../../scripts/app.js";
// Based on NodeAligner by Tenney95 GitHub:https://github.com/Tenney95/ComfyUI-NodeAligner
const kayAlignBottomSvg = `<svg t="1725534360155" class="icon" viewBox="0 0 1170 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1662" width="100%"><path d="M1170.285714 987.428571a36.571429 36.571429 0 0 0-36.571428-36.571428H36.571429a36.571429 36.571429 0 0 0 0 73.142857h1097.142857a36.571429 36.571429 0 0 0 36.571428-36.571429z m-219.428571-146.285714v-512a36.571429 36.571429 0 0 0-36.571429-36.571428h-219.428571a36.571429 36.571429 0 0 0-36.571429 36.571428v512a36.571429 36.571429 0 0 0 36.571429 36.571429h219.428571a36.571429 36.571429 0 0 0 36.571429-36.571429z m-438.857143 0V36.571429a36.571429 36.571429 0 0 0-36.571429-36.571429h-219.428571a36.571429 36.571429 0 0 0-36.571429 36.571429v804.571428a36.571429 36.571429 0 0 0 36.571429 36.571429h219.428571a36.571429 36.571429 0 0 0 36.571429-36.571429z" fill="#666666" p-id="1663"></path></svg>`;
const kayAlignCenterHorizontallySvg = `<svg t="1725534379860" class="icon" viewBox="0 0 1243 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2250" width="100%"><path d="M548.571429 472.356571h146.285714V231.643429a36.571429 36.571429 0 0 1 36.571428-36.571429h219.428572a36.571429 36.571429 0 0 1 36.571428 36.571429v240.713142h179.785143a39.643429 39.643429 0 0 1 0 79.286858H987.428571v240.713142a36.571429 36.571429 0 0 1-36.571428 36.571429h-219.428572a36.571429 36.571429 0 0 1-36.571428-36.571429V551.64571h-146.285714V950.857143a36.571429 36.571429 0 0 1-36.571429 36.571428H292.571429a36.571429 36.571429 0 0 1-36.571429-36.571428V551.643429H76.214857a39.643429 39.643429 0 1 1 0-79.286858H256V73.142857A36.571429 36.571429 0 0 1 292.571429 36.571429h219.428571a36.571429 36.571429 0 0 1 36.571429 36.571428v399.213714z" fill="#666666" p-id="2251"></path></svg>`;
const kayAlignCenterVerticallySvg = `<svg t="1725534363707" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1809" width="100%"><path d="M477.312 576V448H266.688a32 32 0 0 1-32-32v-192a32 32 0 0 1 32-32h210.624V34.688a34.688 34.688 0 0 1 69.376 0V192h210.624a32 32 0 0 1 32 32v192a32 32 0 0 1-32 32H546.688v128H896a32 32 0 0 1 32 32v192a32 32 0 0 1-32 32H546.688v157.312a34.688 34.688 0 0 1-69.376 0V832H128a32 32 0 0 1-32-32v-192A32 32 0 0 1 128 576h349.312z" fill="#666666" p-id="1810"></path></svg>`;
const kayAlignLeftSvg = `<svg t="1725534370541" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2103" width="100%"><path d="M96 0a32 32 0 0 1 32 32v960a32 32 0 0 1-64 0V32A32 32 0 0 1 96 0z m128 192h448a32 32 0 0 1 32 32v192a32 32 0 0 1-32 32h-448a32 32 0 0 1-32-32v-192a32 32 0 0 1 32-32z m0 384h704a32 32 0 0 1 32 32v192a32 32 0 0 1-32 32h-704a32 32 0 0 1-32-32v-192a32 32 0 0 1 32-32z" fill="#666666" p-id="2104"></path></svg>`;
const kayAlignRightSvg = `<svg t="1725534384109" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2397" width="100%"><path d="M928 0a32 32 0 0 1 32 32v960a32 32 0 0 1-64 0V32a32 32 0 0 1 32-32z m-576 192h448a32 32 0 0 1 32 32v192a32 32 0 0 1-32 32h-448a32 32 0 0 1-32-32v-192a32 32 0 0 1 32-32z m-256 384h704a32 32 0 0 1 32 32v192a32 32 0 0 1-32 32H96a32 32 0 0 1-32-32v-192A32 32 0 0 1 96 576z" fill="#666666" p-id="2398"></path></svg>`;
const kayAlignTopSvg = `<svg t="1725534367556" class="icon" viewBox="0 0 1170 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1956" width="100%"><path d="M1170.285714 36.571429a36.571429 36.571429 0 0 1-36.571428 36.571428H36.571429a36.571429 36.571429 0 0 1 0-73.142857h1097.142857a36.571429 36.571429 0 0 1 36.571428 36.571429z m-219.428571 146.285714v512a36.571429 36.571429 0 0 1-36.571429 36.571428h-219.428571a36.571429 36.571429 0 0 1-36.571429-36.571428v-512a36.571429 36.571429 0 0 1 36.571429-36.571429h219.428571a36.571429 36.571429 0 0 1 36.571429 36.571429z m-438.857143 0v804.571428a36.571429 36.571429 0 0 1-36.571429 36.571429h-219.428571a36.571429 36.571429 0 0 1-36.571429-36.571429v-804.571428a36.571429 36.571429 0 0 1 36.571429-36.571429h219.428571a36.571429 36.571429 0 0 1 36.571429 36.571429z" fill="#666666" p-id="1957"></path></svg>`;
const kayEqualWidthSvg = `<svg t="1725606034670" class="icon" viewBox="0 0 1088 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7213" width="100%"><path d="M978.24 480a42.688 42.688 0 0 1-42.688 42.688H172.928a42.688 42.688 0 0 1-42.688-42.688V213.312c0-23.552 19.072-42.624 42.688-42.624h762.624c23.552 0 42.688 19.072 42.688 42.624V480z" fill="#666666" p-id="7214"></path><path d="M256.96 734.144c0-14.08 11.456-25.6 25.6-25.6h543.36a25.6 25.6 0 0 1 0 51.2h-543.36a25.6 25.6 0 0 1-25.6-25.6z" fill="#666666" p-id="7215"></path><path d="M136.64 745.216a12.8 12.8 0 0 1 0-22.144l184.192-106.368a12.8 12.8 0 0 1 19.2 11.072v212.736a12.8 12.8 0 0 1-19.2 11.072l-184.192-106.368zM971.84 745.216a12.8 12.8 0 0 0 0-22.144l-184.256-106.368a12.8 12.8 0 0 0-19.2 11.072v212.736a12.8 12.8 0 0 0 19.2 11.072l184.256-106.368z" fill="#666666" p-id="7216"></path></svg>`;
const kayEqualHeightSvg = `<svg t="1725606224564" class="icon" viewBox="0 0 1088 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7790" width="100%"><path d="M572.16 936a42.688 42.688 0 0 1-42.688-42.688V130.688c0-23.616 19.136-42.688 42.688-42.688h266.688c23.552 0 42.624 19.072 42.624 42.688v762.624a42.688 42.688 0 0 1-42.624 42.688H572.16z" fill="#666666" p-id="7791"></path><path d="M318.016 214.72c14.08 0 25.6 11.456 25.6 25.6v543.36a25.6 25.6 0 1 1-51.2 0v-543.36c0-14.144 11.456-25.6 25.6-25.6z" fill="#666666" p-id="7792"></path><path d="M306.944 94.4a12.8 12.8 0 0 1 22.144 0l106.368 184.192a12.8 12.8 0 0 1-11.072 19.2H211.648a12.8 12.8 0 0 1-11.072-19.2l106.368-184.192zM306.944 929.6a12.8 12.8 0 0 0 22.144 0l106.368-184.192a12.8 12.8 0 0 0-11.072-19.2H211.648a12.8 12.8 0 0 0-11.072 19.2l106.368 184.192z" fill="#666666" p-id="7793"></path></svg>`;
const kayHorizontalDistributionSvg = `<svg t="1725534354023" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1515" width="100%"><path d="M96 0a32 32 0 0 1 32 32v960a32 32 0 0 1-64 0V32A32 32 0 0 1 96 0z m832 0a32 32 0 0 1 32 32v960a32 32 0 0 1-64 0V32a32 32 0 0 1 32-32zM384 800v-576a32 32 0 0 1 32-32h192a32 32 0 0 1 32 32v576a32 32 0 0 1-32 32h-192a32 32 0 0 1-32-32z" fill="#666666" p-id="1516"></path></svg>`;
const kayVerticalDistributionSvg = `<svg t="1725534350231" class="icon" viewBox="0 0 1170 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1368" width="100%"><path d="M1170.285714 36.571429a36.571429 36.571429 0 0 1-36.571428 36.571428H36.571429a36.571429 36.571429 0 0 1 0-73.142857h1097.142857a36.571429 36.571429 0 0 1 36.571428 36.571429z m0 950.857142a36.571429 36.571429 0 0 1-36.571428 36.571429H36.571429a36.571429 36.571429 0 0 1 0-73.142857h1097.142857a36.571429 36.571429 0 0 1 36.571428 36.571428zM256 365.714286h658.285714a36.571429 36.571429 0 0 1 36.571429 36.571428v219.428572a36.571429 36.571429 0 0 1-36.571429 36.571428h-658.285714a36.571429 36.571429 0 0 1-36.571429-36.571428v-219.428572a36.571429 36.571429 0 0 1 36.571429-36.571428z" fill="#666666" p-id="1369"></path></svg>`;
let stylesInjected = false;
// 工具栏永远是 document.body 上的 fixed 元素,不插进 ComfyUI 的 DOM。
// 前端的顶栏由 Vue 托管,往里塞节点会在重渲染时被清掉;而挂到官方遗留插槽
// (app.menu.element) 又会被关进「运行」按钮所在的卡片里,跟着它一起浮动。
// 所以「吸附」只做位置吸附:读一下顶栏的几何位置对齐过去,DOM 上互不相干。
const KayNodeAlignmentManager = {
isInitialized: false,
toolbarContainer: null,
dragState: { isDragging: false, offsetX: 0, offsetY: 0 },
hasShownTooltip: false,
isVisible: true,
position: { leftPercentage: 50, topPercentage: 5, docked: false },
dockGuide: null,
bandObserver: null,
async init() {
if (this.isInitialized) return;
this.canvas = await this.waitForCanvas();
this.injectStyles();
this.loadPosition();
this.setupToolbar();
this.restorePosition();
this.bindCanvasEvents();
this.bindKeyboardShortcuts();
this.bindBandTracking();
const displayMode = app.ui.settings.getSettingValue("KayTool.NodeAlignDisplayMode");
this.updateDisplayMode(displayMode);
// 放在最后:初始化中途失败时不要留下「已初始化」的假象,挡住下次重试。
this.isInitialized = true;
},
// 只等画布,并且带超时。等不到也照常初始化,最多是快捷键的点击刷新失效,
// 绝不像以前那样卡在 requestAnimationFrame 里空转到天荒地老。
waitForCanvas(timeout = 10000) {
return new Promise(resolve => {
const deadline = performance.now() + timeout;
const check = () => {
const canvas = app.canvasEl || document.querySelector('#graph-canvas') || app.canvas?.canvas || null;
if (canvas || performance.now() > deadline) resolve(canvas);
else requestAnimationFrame(check);
};
check();
});
},
// 顶栏所在的那条横带,只用来算几何,取不到就退回一个合理的默认值。
getDockBand() {
const probe = document.querySelector('[data-testid="action-bar-card"]')
|| document.querySelector('[data-testid="top-menu-actionbars"]')
|| app.menu?.element
|| null;
const rect = probe?.getBoundingClientRect();
if (rect && rect.height > 0) {
return { top: rect.top, bottom: rect.bottom, height: rect.height, probe };
}
const tabs = document.querySelector('[data-testid="topbar-workflow-tabs"]');
const top = tabs ? tabs.getBoundingClientRect().bottom + 4 : 40;
return { top, bottom: top + 48, height: 48, probe: null };
},
// 吸附时横向避让「运行」那张卡片,免得盖住官方按钮。
// 让位的结果必须还在视口里,否则就换另一边;两边都塞不下才作罢。
avoidActionBar(left, width, viewportWidth) {
const card = document.querySelector('[data-testid="action-bar-card"]');
const rect = card?.getBoundingClientRect();
if (!rect || rect.width === 0) return left;
if (left >= rect.right || left + width <= rect.left) return left;
const gap = 8;
const leftSlot = rect.left - width - gap;
const rightSlot = rect.right + gap;
const leftFits = leftSlot >= 0;
const rightFits = rightSlot + width <= viewportWidth;
const preferLeft = (left + width / 2) < (rect.left + rect.width / 2);
if (preferLeft && leftFits) return leftSlot;
if (!preferLeft && rightFits) return rightSlot;
if (leftFits) return leftSlot;
if (rightFits) return rightSlot;
return left;
},
injectStyles() {
if (stylesInjected) return;
const bgColor = app.ui.settings.getSettingValue("KayTool.NodeAlignBackgroundColor");
const iconColor = app.ui.settings.getSettingValue("KayTool.NodeAlignIconColor");
const dividerColor = app.ui.settings.getSettingValue("KayTool.NodeAlignDividerColor");
document.head.insertAdjacentHTML('beforeend', `<style>
#kay-node-alignment-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 4px;
z-index: 10000;
height: 32px;
white-space: nowrap;
user-select: none;
pointer-events: auto;
}
#kay-node-alignment-toolbar.floating { position: fixed; }
#kay-node-alignment-toolbar.docked { position: fixed; }
.kay-align-button {
width: 25px;
height: 25px;
display: flex;
justify-content: center;
align-items: center;
background-color: #${app.ui.settings.getSettingValue("KayTool.NodeAlignIconBackgroundColor")};
border: none;
cursor: pointer;
padding: 0;
border-radius: 4px;
transition: background-color 0.3s ease-in-out;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.kay-align-button svg {
width: 66%;
height: 66%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: transform 0.1s ease;
}
.kay-toolbar-divider {
width: 3.2px;
height: 15px;
background: #${dividerColor};
border-radius: 9px;
cursor: grab;
flex-shrink: 0;
}
.kay-toolbar-divider:active { cursor: grabbing; }
#kay-align-tooltip {
position: absolute;
bottom: -20px;
right: 0;
background: #333;
color: #fff;
padding: 5px 10px;
border-radius: 6px;
display: none;
z-index: 10001;
white-space: nowrap;
font-size: 12px;
}
#kay-dock-guide {
position: fixed;
left: 0;
width: 100%;
border-top: 2px dashed #d0ff00;
border-bottom: 2px dashed #d0ff00;
background: rgba(208, 255, 0, 0.06);
z-index: 9999;
pointer-events: none;
display: none;
}
</style>`);
stylesInjected = true;
},
loadPosition() {
let saved = {};
try {
saved = JSON.parse(localStorage.getItem('KayNodeAlignToolbarPosition')) || {};
} catch (e) {
saved = {};
}
this.position = {
leftPercentage: saved.leftPercentage ?? 50,
topPercentage: saved.topPercentage ?? 5,
// isAttached 是旧版插进顶栏 DOM 的标记,沿用为位置吸附。
docked: saved.docked ?? saved.isAttached ?? false
};
},
savePosition() {
try {
localStorage.setItem('KayNodeAlignToolbarPosition', JSON.stringify(this.position));
} catch (e) { /* 隐私模式下 localStorage 可能不可写,忽略即可 */ }
},
setupToolbar() {
const bgColor = app.ui.settings.getSettingValue("KayTool.NodeAlignBackgroundColor");
const opacity = app.ui.settings.getSettingValue("KayTool.NodeAlignBackgroundOpacity") / 100;
const iconColor = app.ui.settings.getSettingValue("KayTool.NodeAlignIconColor");
this.toolbarContainer = document.createElement('div');
this.toolbarContainer.id = 'kay-node-alignment-toolbar';
this.toolbarContainer.classList.add(this.position.docked ? 'docked' : 'floating');
if (opacity > 0 && /^[0-9A-Fa-f]{6}$/.test(bgColor)) {
this.toolbarContainer.style.background = `rgba(${parseInt(bgColor.substr(0, 2), 16)}, ${parseInt(bgColor.substr(2, 2), 16)}, ${parseInt(bgColor.substr(4, 2), 16)}, ${opacity})`;
}
document.body.appendChild(this.toolbarContainer);
// 吸附提示条:拖到顶栏那一行时亮起,全程只动我们自己的元素。
this.dockGuide = document.createElement('div');
this.dockGuide.id = 'kay-dock-guide';
document.body.appendChild(this.dockGuide);
this.getAlignmentButtons().forEach(btn => {
const el = document.createElement(btn.type === 'divider' ? 'div' : 'button');
el.className = btn.type === 'divider' ? 'kay-toolbar-divider' : 'kay-align-button';
if (btn.type !== 'divider') {
el.id = btn.id;
el.innerHTML = btn.svg.replace(/fill="#666666"/g, `fill="#${iconColor}"`);
el.addEventListener('click', e => btn.action.call(this, e));
el.addEventListener('mouseover', () => {
const baseColor = app.ui.settings.getSettingValue("KayTool.NodeAlignIconBackgroundColor");
el.style.backgroundColor = this.adjustColor(baseColor, 65);
});
el.addEventListener('mouseout', () => {
const baseColor = app.ui.settings.getSettingValue("KayTool.NodeAlignIconBackgroundColor");
el.style.backgroundColor = `#${baseColor}`;
});
el.addEventListener('mousedown', () => el.style.transform = 'scale(0.95)');
el.addEventListener('mouseup', () => el.style.transform = '');
el.addEventListener('mouseleave', () => el.style.transform = '');
} else {
el.addEventListener('mousedown', e => this.onDragStart(e));
}
this.toolbarContainer.appendChild(el);
});
document.addEventListener('mousemove', this.onDragging.bind(this));
document.addEventListener('mouseup', this.onDragEnd.bind(this));
document.addEventListener('selectstart', e => this.dragState.isDragging && e.preventDefault());
this.addTooltip();
},
adjustColor(hex, amount) {
let color = hex.replace("#", "");
if (color.length === 3) color = color.split('').map(c => c + c).join('');
const r = parseInt(color.substr(0, 2), 16);
const g = parseInt(color.substr(2, 2), 16);
const b = parseInt(color.substr(4, 2), 16);
const brightness = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
const adjust = brightness > 127 ? -amount : amount;
return `#${[r, g, b].map(c => Math.max(0, Math.min(255, c + adjust)).toString(16).padStart(2, '0')).join('')}`;
},
getAlignmentButtons() {
return [
// 开头这根就是工具栏自己的拖拽手柄,和「运行」按钮那张卡片互不相干。
{ type: 'divider' },
{ id: 'kay-align-left', svg: kayAlignLeftSvg, action: this.alignLeft },
{ id: 'kay-align-center-vertically', svg: kayAlignCenterVerticallySvg, action: this.alignCenterVertically },
{ id: 'kay-align-right', svg: kayAlignRightSvg, action: this.alignRight },
{ type: 'divider' },
{ id: 'kay-align-top', svg: kayAlignTopSvg, action: this.alignTop },
{ id: 'kay-align-center-horizontally', svg: kayAlignCenterHorizontallySvg, action: this.alignCenterHorizontally },
{ id: 'kay-align-bottom', svg: kayAlignBottomSvg, action: this.alignBottom },
{ type: 'divider' },
{ id: 'kay-equal-width', svg: kayEqualWidthSvg, action: this.equalWidth },
{ id: 'kay-equal-height', svg: kayEqualHeightSvg, action: this.equalHeight },
{ type: 'divider' },
{ id: 'kay-horizontal-distribution', svg: kayHorizontalDistributionSvg, action: this.horizontalDistribution },
{ id: 'kay-vertical-distribution', svg: kayVerticalDistributionSvg, action: this.verticalDistribution }
];
},
addTooltip() {
const tooltip = document.createElement('div');
tooltip.id = 'kay-align-tooltip';
tooltip.textContent = 'Node alignment toolbar';
this.toolbarContainer.appendChild(tooltip);
if (!this.hasShownTooltip) {
this.toolbarContainer.addEventListener('mouseenter', () => {
if (!this.hasShownTooltip) {
setTimeout(() => {
tooltip.style.display = 'block';
this.hasShownTooltip = true;
}, 1000);
}
});
this.toolbarContainer.addEventListener('mouseleave', () => tooltip.remove());
}
},
show() {
this.isVisible = true;
if (this.toolbarContainer) {
this.toolbarContainer.style.display = 'flex';
this.updatePosition();
}
},
hide() {
this.isVisible = false;
if (this.toolbarContainer) {
this.toolbarContainer.style.display = 'none';
}
},
updatePosition() {
if (!this.toolbarContainer || !this.isVisible) return;
if (this.position.docked) {
this.applyDockedPosition();
return;
}
const { windowRect, toolbarRect } = this.getRect();
let left = (this.position.leftPercentage / 100) * windowRect.width - toolbarRect.width / 2;
let top = (this.position.topPercentage / 100) * windowRect.height;
left = Math.max(0, Math.min(left, windowRect.width - toolbarRect.width));
top = Math.max(0, Math.min(top, windowRect.height - toolbarRect.height));
this.toolbarContainer.style.left = `${left}px`;
this.toolbarContainer.style.top = `${top}px`;
},
// 吸附态:纵向对齐到顶栏中线,横向保留用户拖到的位置(避让运行卡片)。
applyDockedPosition() {
if (!this.toolbarContainer) return;
const { windowRect, toolbarRect } = this.getRect();
const band = this.getDockBand();
let left = (this.position.leftPercentage / 100) * windowRect.width - toolbarRect.width / 2;
left = Math.max(0, Math.min(left, windowRect.width - toolbarRect.width));
// 避让本身已经考虑了视口边界,这里不能再夹一次,否则会把它推回重叠区。
left = this.avoidActionBar(left, toolbarRect.width, windowRect.width);
const top = band.top + (band.height - toolbarRect.height) / 2;
this.toolbarContainer.style.left = `${left}px`;
this.toolbarContainer.style.top = `${Math.max(0, top)}px`;
},
getRect() {
return {
windowRect: { width: window.innerWidth, height: window.innerHeight },
toolbarRect: this.toolbarContainer.getBoundingClientRect()
};
},
// 工具栏竖直方向是否压在顶栏那条横带上
isOverBand(toolbarRect, band) {
return toolbarRect.top < band.bottom && toolbarRect.top + toolbarRect.height > band.top;
},
showDockGuide(band) {
if (!this.dockGuide) return;
this.dockGuide.style.top = `${band.top}px`;
this.dockGuide.style.height = `${band.height}px`;
this.dockGuide.style.display = 'block';
},
hideDockGuide() {
if (this.dockGuide) this.dockGuide.style.display = 'none';
},
onDragStart(e) {
e.preventDefault();
const { toolbarRect } = this.getRect();
this.dragState = {
isDragging: true,
offsetX: e.clientX - toolbarRect.left,
offsetY: e.clientY - toolbarRect.top
};
this.setDocked(false);
},
onDragging(e) {
if (!this.dragState.isDragging) return;
const { windowRect, toolbarRect } = this.getRect();
let left = e.clientX - this.dragState.offsetX;
let top = e.clientY - this.dragState.offsetY;
left = Math.max(0, Math.min(left, windowRect.width - toolbarRect.width));
top = Math.max(0, Math.min(top, windowRect.height - toolbarRect.height));
this.toolbarContainer.style.left = `${left}px`;
this.toolbarContainer.style.top = `${top}px`;
const band = this.getDockBand();
if (this.isOverBand({ ...toolbarRect.toJSON(), top }, band)) this.showDockGuide(band);
else this.hideDockGuide();
},
onDragEnd() {
if (!this.dragState.isDragging) return;
this.dragState = { isDragging: false, offsetX: 0, offsetY: 0 };
this.hideDockGuide();
const { windowRect, toolbarRect } = this.getRect();
this.position.leftPercentage = ((toolbarRect.left + toolbarRect.width / 2) / windowRect.width) * 100;
this.position.topPercentage = (toolbarRect.top / windowRect.height) * 100;
const band = this.getDockBand();
if (this.isOverBand(toolbarRect, band)) {
this.setDocked(true);
this.applyDockedPosition();
} else {
this.setDocked(false);
}
this.savePosition();
},
setDocked(docked) {
this.position.docked = docked;
if (!this.toolbarContainer) return;
this.toolbarContainer.classList.toggle('docked', docked);
this.toolbarContainer.classList.toggle('floating', !docked);
},
restorePosition() {
this.setDocked(this.position.docked);
this.updatePosition();
},
// 顶栏高度会随工作流标签栏出现/消失而变,吸附时跟着重新对齐。
// 用事件驱动,不做轮询。
bindBandTracking() {
const realign = () => {
if (this.position.docked && this.isVisible && !this.dragState.isDragging) {
this.applyDockedPosition();
}
};
window.addEventListener('resize', realign);
if (typeof ResizeObserver !== 'undefined') {
this.bandObserver = new ResizeObserver(realign);
const probe = this.getDockBand().probe;
if (probe) this.bandObserver.observe(probe);
}
},
getSelectedNodes() {
const nodes = Object.values(app.canvas.selected_nodes || {});
return nodes;
},
alignLeft() {
const nodes = this.getSelectedNodes();
const minX = Math.min(...nodes.map(n => n.pos[0]));
nodes.forEach(n => n.pos[0] = minX);
this.redraw();
},
alignRight() {
const nodes = this.getSelectedNodes();
const maxX = Math.max(...nodes.map(n => n.pos[0] + n.size[0]));
nodes.forEach(n => n.pos[0] = maxX - n.size[0]);
this.redraw();
},
alignTop() {
const nodes = this.getSelectedNodes();
const minY = Math.min(...nodes.map(n => n.pos[1]));
nodes.forEach(n => n.pos[1] = minY);
this.redraw();
},
alignBottom() {
const nodes = this.getSelectedNodes();
const maxY = Math.max(...nodes.map(n => n.pos[1] + n.size[1]));
nodes.forEach(n => n.pos[1] = maxY - n.size[1]);
this.redraw();
},
alignCenterHorizontally() {
const nodes = this.getSelectedNodes();
const minY = Math.min(...nodes.map(n => n.pos[1]));
const maxY = Math.max(...nodes.map(n => n.pos[1] + n.size[1]));
const centerY = (minY + maxY) / 2;
nodes.forEach(n => n.pos[1] = centerY - n.size[1] / 2);
this.redraw();
},
alignCenterVertically() {
const nodes = this.getSelectedNodes();
const minX = Math.min(...nodes.map(n => n.pos[0]));
const maxX = Math.max(...nodes.map(n => n.pos[0] + n.size[0]));
const centerX = (minX + maxX) / 2;
nodes.forEach(n => n.pos[0] = centerX - n.size[0] / 2);
this.redraw();
},
equalWidth() {
const nodes = this.getSelectedNodes();
if (nodes.length) nodes.forEach(n => n.size[0] = nodes[0].size[0]);
this.redraw();
},
equalHeight() {
const nodes = this.getSelectedNodes();
if (nodes.length) nodes.forEach(n => n.size[1] = nodes[0].size[1]);
this.redraw();
},
horizontalDistribution() {
const nodes = this.getSelectedNodes();
const axis = 0;
if (nodes.length > 1) {
nodes.sort((a, b) => a.pos[axis] - b.pos[axis]);
const min = Math.min(...nodes.map(node => node.pos[axis]));
const max = Math.max(...nodes.map(node => node.pos[axis] + node.size[axis]));
const totalSize = nodes.reduce((sum, node) => sum + node.size[axis], 0);
const spacing = (max - min - totalSize) / (nodes.length - 1);
let current = min;
nodes.forEach(node => {
node.pos[axis] = current;
current += node.size[axis] + spacing;
});
this.redraw();
}
},
verticalDistribution() {
const nodes = this.getSelectedNodes();
if (nodes.length > 1) {
const axis = 1;
const otherAxis = 0;
const tolerance = 100;
const minSpacing = 20;
const columns = [];
nodes.forEach(node => {
let foundColumn = null;
for (let column of columns) {
const columnX = column[0].pos[otherAxis];
if (Math.abs(columnX - node.pos[otherAxis]) <= tolerance) {
foundColumn = column;
break;
}
}
if (foundColumn) {
foundColumn.push(node);
} else {
columns.push([node]);
}
});
const columnHeights = columns.map(column => {
const minY = Math.min(...column.map(node => node.pos[axis]));
const maxY = Math.max(...column.map(node => node.pos[axis] + node.size[axis]));
return maxY - minY;
});
const maxColumnHeight = Math.max(...columnHeights);
columns.forEach(column => {
if (column.length > 1) {
column.sort((a, b) => a.pos[axis] - b.pos[axis]);
const minY = Math.min(...column.map(node => node.pos[axis]));
const totalSize = column.reduce((sum, node) => sum + node.size[axis], 0);
let spacing = (maxColumnHeight - totalSize) / (column.length - 1);
spacing = Math.max(spacing, minSpacing);
let currentY = column[0].pos[axis];
column.forEach((node, idx) => {
if (idx === 0) {
currentY += node.size[axis] + spacing;
} else {
node.pos[axis] = currentY;
currentY += node.size[axis] + spacing;
}
node.pos[otherAxis] = column[0].pos[otherAxis];
});
}
});
this.redraw();
}
},
redraw() {
if (app.canvas) app.canvas.setDirty(true, true);
},
updateDisplayMode(mode) {
const effectiveMode = mode || "permanent";
if (effectiveMode === "disabled") {
this.hide();
} else if (effectiveMode === "permanent") {
this.show();
} else if (effectiveMode === "on-select") {
const selectedNodes = this.getSelectedNodes();
if (selectedNodes.length >= 2) this.show();
else this.hide();
}
},
bindCanvasEvents() {
if (!this.canvas) return;
this.canvas.addEventListener('click', () => {
const currentMode = app.ui.settings.getSettingValue("KayTool.NodeAlignDisplayMode");
this.updateDisplayMode(currentMode);
});
},
bindKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// 只在 Shift 键按下且没有其他修饰键(如 Ctrl、Alt)时检查 WASD
if (!e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
const enableShortcuts = app.ui.settings.getSettingValue("KayTool.EnableAlignmentShortcuts");
if (!enableShortcuts || this.getSelectedNodes().length < 2) return;
switch (e.key.toLowerCase()) {
case 'w':
e.preventDefault();
this.alignTop();
break;
case 'a':
e.preventDefault();
this.alignLeft();
break;
case 's':
e.preventDefault();
this.alignBottom();
break;
case 'd':
e.preventDefault();
this.alignRight();
break;
}
});
}
};
function initializeKayNodeAlignment() {
if (!KayNodeAlignmentManager.isInitialized) {
KayNodeAlignmentManager.init();
} else {
const displayMode = app.ui.settings.getSettingValue("KayTool.NodeAlignDisplayMode");
KayNodeAlignmentManager.updateDisplayMode(displayMode);
}
}
app.registerExtension({
name: "KayTool.NodeAlign",
init() {
initializeKayNodeAlignment();
window.KayNodeAlignmentManager = KayNodeAlignmentManager;
window.initializeKayNodeAlignment = initializeKayNodeAlignment;
}
});