import { app } from "../../../scripts/app.js";
// SVG 定义(已包含你的现有 SVG,新增参考代码中的两个)
const kayAlignBottomSvg = ``;
const kayAlignCenterHorizontallySvg = ``;
const kayAlignCenterVerticallySvg = ``;
const kayAlignLeftSvg = ``;
const kayAlignRightSvg = ``;
const kayAlignTopSvg = ``;
const kayEqualWidthSvg = ``;
const kayEqualHeightSvg = ``;
const kayHorizontalDistributionSvg = ``;
const kayVerticalDistributionSvg = ``;
let stylesInjected = false;
const KayNodeAlignmentManager = {
isInitialized: false,
toolbarContainer: null,
dragState: { isDragging: false, offsetX: 0, offsetY: 0 },
hasShownTooltip: false,
isVisible: true,
position: { leftPercentage: 50, topPercentage: 5, isAttached: false, insertIndex: 0 },
menuElement: null,
insertionIndicator: null,
async init() {
if (this.isInitialized) return;
this.isInitialized = true;
await this.waitForElements();
this.injectStyles();
this.loadPosition();
this.setupToolbar();
this.restorePosition();
this.bindCanvasEvents();
const displayMode = app.ui.settings.getSettingValue("KayTool.NodeAlignDisplayMode") || "permanent";
this.updateDisplayMode(displayMode);
},
waitForElements() {
return new Promise(resolve => {
const checkElements = () => {
this.canvas = document.querySelector('#graph-canvas');
this.menuElement = document.querySelector('.comfyui-menu');
if (this.canvas && this.menuElement) {
resolve();
} else {
requestAnimationFrame(checkElements);
}
};
requestAnimationFrame(checkElements);
});
},
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', ``);
stylesInjected = true;
},
loadPosition() {
const savedPosition = JSON.parse(localStorage.getItem('KayNodeAlignToolbarPosition')) || {};
this.position = {
leftPercentage: savedPosition.leftPercentage || 50,
topPercentage: savedPosition.topPercentage || 5,
isAttached: savedPosition.isAttached || false,
insertIndex: savedPosition.insertIndex || 0
};
},
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.isAttached ? 'attached' : '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})`;
}
if (this.position.isAttached) {
const menuChildren = Array.from(this.menuElement.children).filter(child => child !== this.insertionIndicator);
const insertIndex = Math.min(this.position.insertIndex, menuChildren.length);
const insertBeforeElement = menuChildren[insertIndex] || null;
this.menuElement.insertBefore(this.toolbarContainer, insertBeforeElement);
} else {
document.body.appendChild(this.toolbarContainer);
}
this.insertionIndicator = document.createElement('div');
this.insertionIndicator.id = 'kay-insertion-indicator';
this.insertionIndicator.style.display = 'none';
this.menuElement.appendChild(this.insertionIndicator);
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 [
{ 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';
if (!this.position.isAttached) this.updatePosition();
}
},
hide() {
this.isVisible = false;
if (this.toolbarContainer) {
this.toolbarContainer.style.display = 'none';
}
},
updatePosition() {
if (!this.toolbarContainer || !this.isVisible || this.position.isAttached) 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`;
},
getRect() {
return {
windowRect: { width: window.innerWidth, height: window.innerHeight },
toolbarRect: this.toolbarContainer.getBoundingClientRect(),
menuRect: this.menuElement.getBoundingClientRect()
};
},
onDragStart(e) {
e.preventDefault();
const { toolbarRect } = this.getRect();
this.dragState = {
isDragging: true,
offsetX: e.clientX - toolbarRect.left,
offsetY: e.clientY - toolbarRect.top
};
},
onDragging(e) {
if (!this.dragState.isDragging) return;
const { windowRect, toolbarRect, menuRect } = this.getRect();
if (this.position.isAttached) {
this.detachFromMenu(true);
this.menuElement.style.backgroundColor = '#d0ff00';
}
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 toolbarBottom = toolbarRect.top + toolbarRect.height;
if (toolbarBottom > menuRect.top && toolbarRect.top < menuRect.bottom) {
this.menuElement.style.backgroundColor = '#000000';
this.insertionIndicator.style.display = 'block';
const menuChildren = Array.from(this.menuElement.children).filter(child => child !== this.insertionIndicator && child !== this.toolbarContainer);
const toolbarCenterX = toolbarRect.left + toolbarRect.width / 2;
let indicatorLeft = 0;
for (let i = 0; i < menuChildren.length; i++) {
const childRect = menuChildren[i].getBoundingClientRect();
if (toolbarCenterX < childRect.left + childRect.width / 2) {
indicatorLeft = childRect.left - menuRect.left;
break;
}
indicatorLeft = menuChildren[menuChildren.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.menuElement.style.backgroundColor = '#d0ff00';
this.insertionIndicator.style.display = 'none';
}
},
onDragEnd() {
if (!this.dragState.isDragging) return;
this.dragState.isDragging = false;
this.dragState.offsetX = 0;
this.dragState.offsetY = 0;
const { windowRect, toolbarRect, menuRect } = this.getRect();
if (toolbarRect.top + toolbarRect.height > menuRect.top && toolbarRect.top < menuRect.bottom) {
const menuChildren = Array.from(this.menuElement.children).filter(child => child !== this.insertionIndicator && child !== this.toolbarContainer);
const toolbarCenterX = toolbarRect.left + toolbarRect.width / 2;
let insertIndex = 0;
for (let i = 0; i < menuChildren.length; i++) {
const childRect = menuChildren[i].getBoundingClientRect();
if (toolbarCenterX < childRect.left + childRect.width / 2) {
insertIndex = i;
break;
}
insertIndex = i + 1;
}
this.attachToMenu(menuChildren[insertIndex]);
this.position.isAttached = true;
this.position.insertIndex = insertIndex;
} else {
this.position.leftPercentage = ((toolbarRect.left + toolbarRect.width / 2) / windowRect.width) * 100;
this.position.topPercentage = (toolbarRect.top / windowRect.height) * 100;
this.position.isAttached = false;
this.position.insertIndex = 0;
}
localStorage.setItem('KayNodeAlignToolbarPosition', JSON.stringify(this.position));
this.menuElement.style.backgroundColor = '';
this.insertionIndicator.style.display = 'none';
},
attachToMenu(insertBeforeElement) {
this.position.isAttached = true;
this.toolbarContainer.classList.remove('floating');
this.toolbarContainer.classList.add('attached');
this.toolbarContainer.style.left = '';
this.toolbarContainer.style.top = '';
this.menuElement.insertBefore(this.toolbarContainer, insertBeforeElement || null);
},
detachFromMenu(isDragging) {
if (!isDragging) return;
this.position.isAttached = false;
this.toolbarContainer.classList.remove('attached');
this.toolbarContainer.classList.add('floating');
document.body.appendChild(this.toolbarContainer);
this.updatePosition();
},
restorePosition() {
if (!this.position.isAttached || !this.menuElement || !this.toolbarContainer) {
this.position.isAttached = false;
this.updatePosition();
return;
}
const menuChildren = Array.from(this.menuElement.children).filter(child => child !== this.insertionIndicator && child !== this.toolbarContainer);
const insertIndex = Math.min(this.position.insertIndex, menuChildren.length);
this.attachToMenu(menuChildren[insertIndex]);
},
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") || "permanent";
this.updateDisplayMode(currentMode);
});
}
};
function initializeKayNodeAlignment() {
if (!KayNodeAlignmentManager.isInitialized) {
KayNodeAlignmentManager.init();
} else {
const displayMode = app.ui.settings.getSettingValue("KayTool.NodeAlignDisplayMode") || "permanent";
KayNodeAlignmentManager.updateDisplayMode(displayMode);
}
}
app.registerExtension({
name: "KayTool.NodeAlign",
init() {
initializeKayNodeAlignment();
window.KayNodeAlignmentManager = KayNodeAlignmentManager;
window.initializeKayNodeAlignment = initializeKayNodeAlignment;
}
});