981 lines
40 KiB
JavaScript
981 lines
40 KiB
JavaScript
import { SVG, STYLES } from './NodeDesignConstants.js';
|
|
|
|
function normalizeColorForInput(color, fallback = '#000000') {
|
|
if (typeof color !== 'string') return fallback;
|
|
if (/^#[0-9a-fA-F]{6}$/.test(color)) return color;
|
|
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
|
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
const ButtonManager = {
|
|
isInitialized: false,
|
|
buttonContainer: null,
|
|
isPermanent: true,
|
|
dragStartX: 0,
|
|
dragStartY: 0,
|
|
isDragging: false,
|
|
boundOnDragging: null,
|
|
boundOnDragEnd: null,
|
|
undoStack: [],
|
|
redoStack: [],
|
|
maxUndoStackSize: 50,
|
|
colorPicker: null,
|
|
bgColorPicker: null,
|
|
isUpdatingPickers: false,
|
|
isVisible: false,
|
|
isClosedByUser: false,
|
|
init() {
|
|
if (this.isInitialized) {
|
|
return;
|
|
}
|
|
const style = document.createElement('style');
|
|
style.textContent = STYLES;
|
|
document.head.appendChild(style);
|
|
this.buttonContainer = document.createElement('div');
|
|
this.buttonContainer.id = 'nd-alignment-buttons';
|
|
this.buttonContainer.style.position = 'fixed';
|
|
this.buttonContainer.style.zIndex = '9999';
|
|
this.buttonContainer.style.left = '0px';
|
|
this.buttonContainer.style.top = '0px';
|
|
this.buttonContainer.style.visibility = 'hidden';
|
|
document.body.appendChild(this.buttonContainer);
|
|
this.boundOnDragging = this.onDragging.bind(this);
|
|
this.boundOnDragEnd = this.onDragEnd.bind(this);
|
|
this.buttonContainer.addEventListener('mousedown', this.onDragStart.bind(this));
|
|
document.addEventListener('mousemove', this.boundOnDragging);
|
|
document.addEventListener('mouseup', this.boundOnDragEnd);
|
|
this.createButtons();
|
|
this.setupContextMenu();
|
|
this.isInitialized = true;
|
|
this.setInitialVisibility();
|
|
this.initUndoRedo();
|
|
this.updateToggleButton();
|
|
},
|
|
initUndoRedo() {
|
|
},
|
|
addToUndoStack(action) {
|
|
this.undoStack.push(action);
|
|
if (this.undoStack.length > this.maxUndoStackSize) {
|
|
this.undoStack.shift();
|
|
}
|
|
this.redoStack = [];
|
|
this.updateUndoRedoButtons();
|
|
},
|
|
executeAction(fromStack, toStack, actionMethod) {
|
|
if (fromStack.length === 0) return;
|
|
const action = fromStack.pop();
|
|
try {
|
|
if (action && typeof action[actionMethod] === 'function') {
|
|
action[actionMethod]();
|
|
toStack.push(action);
|
|
} else {
|
|
console.warn(`Invalid action or missing ${actionMethod} method`);
|
|
return;
|
|
}
|
|
try {
|
|
this.updateCanvas();
|
|
} catch (canvasError) {
|
|
console.warn(`Error updating canvas after ${actionMethod}:`, canvasError);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Error during ${actionMethod} operation:`, error);
|
|
fromStack.push(action);
|
|
} finally {
|
|
this.updateUndoRedoButtons();
|
|
}
|
|
},
|
|
undo() {
|
|
this.executeAction(this.undoStack, this.redoStack, 'undo');
|
|
},
|
|
redo() {
|
|
this.executeAction(this.redoStack, this.undoStack, 'redo');
|
|
},
|
|
updateUndoRedoButtons() {
|
|
const undoButton = document.getElementById('undo');
|
|
const redoButton = document.getElementById('redo');
|
|
if (undoButton) undoButton.disabled = this.undoStack.length === 0;
|
|
if (redoButton) redoButton.disabled = this.redoStack.length === 0;
|
|
},
|
|
createButtons() {
|
|
const colorPickerContainer = document.createElement('div');
|
|
colorPickerContainer.classList.add('nd-color-picker-container');
|
|
this.colorPicker = this.createSingleColorPicker('node-color-picker', 'Select Node Color', this.onNodeColorChange.bind(this));
|
|
this.bgColorPicker = this.createSingleColorPicker('node-bg-color-picker', 'Select Node Background Color', this.onNodeBgColorChange.bind(this));
|
|
colorPickerContainer.appendChild(this.colorPicker);
|
|
colorPickerContainer.appendChild(this.bgColorPicker);
|
|
this.buttonContainer.appendChild(colorPickerContainer);
|
|
const buttons = this.getButtons();
|
|
buttons.forEach(btn => {
|
|
if (btn.type === 'divider') {
|
|
this.createDivider();
|
|
} else {
|
|
this.createButton(btn);
|
|
}
|
|
});
|
|
},
|
|
createDivider() {
|
|
const divider = document.createElement('div');
|
|
divider.classList.add('nd-divider');
|
|
divider.addEventListener('mousedown', this.onDragStart.bind(this));
|
|
this.buttonContainer.appendChild(divider);
|
|
},
|
|
createButton(btn) {
|
|
const button = document.createElement('button');
|
|
button.id = btn.id;
|
|
button.classList.add('nd-button');
|
|
button.innerHTML = SVG[btn.id] || '';
|
|
button.title = this.getButtonTitle(btn.id);
|
|
button.addEventListener('click', (e) => {
|
|
try {
|
|
if (typeof btn.action === 'function') {
|
|
btn.action.call(this, e);
|
|
}
|
|
try {
|
|
this.updateCanvas();
|
|
} catch (canvasError) {
|
|
console.warn(`Error updating canvas after button click:`, canvasError);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Error executing action for button ${btn.id}:`, error);
|
|
}
|
|
});
|
|
this.buttonContainer.appendChild(button);
|
|
},
|
|
getButtonTitle(buttonId) {
|
|
const titles = {
|
|
alignLeft: 'Align Left',
|
|
alignCenterHorizontally: 'Center Horizontally',
|
|
alignRight: 'Align Right',
|
|
alignTop: 'Align Top',
|
|
alignCenterVertically: 'Center Vertically',
|
|
alignBottom: 'Align Bottom',
|
|
horizontalDistribution: 'Distribute Horizontally',
|
|
verticalDistribution: 'Distribute Vertically',
|
|
equalWidth: 'Equal Width',
|
|
equalHeight: 'Equal Height',
|
|
undo: 'Undo',
|
|
redo: 'Redo',
|
|
smartAlign: 'Smart Align',
|
|
treeView: 'Tree View',
|
|
placeRight: 'Place Right (chain)',
|
|
placeBelow: 'Place Below (chain)',
|
|
toggleMode: 'Toggle Mode'
|
|
};
|
|
return titles[buttonId] || '';
|
|
},
|
|
getButtons() {
|
|
return [
|
|
{ id: 'alignLeft', action: () => this._handleAlignmentAction('alignLeft') },
|
|
{ id: 'alignCenterHorizontally', action: () => this._handleAlignmentAction('alignCenterHorizontally') },
|
|
{ id: 'alignRight', action: () => this._handleAlignmentAction('alignRight') },
|
|
{ id: 'alignTop', action: () => this._handleAlignmentAction('alignTop') },
|
|
{ id: 'alignCenterVertically', action: () => this._handleAlignmentAction('alignCenterVertically') },
|
|
{ id: 'alignBottom', action: () => this._handleAlignmentAction('alignBottom') },
|
|
{ id: 'equalWidth', action: () => this._handleAlignmentAction('equalWidth') },
|
|
{ id: 'equalHeight', action: () => this._handleAlignmentAction('equalHeight') },
|
|
{ id: 'horizontalDistribution', action: this.horizontalDistribution },
|
|
{ id: 'verticalDistribution', action: this.verticalDistribution },
|
|
{ id: 'undo', action: this.undo },
|
|
{ id: 'redo', action: this.redo },
|
|
{ id: 'smartAlign', action: this.smartAlign },
|
|
{ id: 'treeView', action: this.treeView },
|
|
{ id: 'placeRight', action: () => this.placeAdjacent('right') },
|
|
{ id: 'placeBelow', action: () => this.placeAdjacent('below') },
|
|
{ id: 'toggleMode', action: this.toggleMode },
|
|
{ type: 'divider' },
|
|
{ id: 'close', action: this.close }
|
|
];
|
|
},
|
|
toggleMode() {
|
|
this.isPermanent = !this.isPermanent;
|
|
localStorage.setItem('NodeAlignerIsPermanent', this.isPermanent ? '1' : '0');
|
|
if (!this.isPermanent) {
|
|
const selectedNodes = this.getSelectedNodes();
|
|
if (selectedNodes.length < 2) {
|
|
this.hide();
|
|
}
|
|
}
|
|
this.updateToggleButton();
|
|
},
|
|
updateToggleButton() {
|
|
const toggleButton = document.getElementById('toggleMode');
|
|
if (toggleButton) {
|
|
toggleButton.classList.toggle('active', this.isPermanent);
|
|
toggleButton.title = this.isPermanent ? 'Show on selection' : 'Show permanently';
|
|
}
|
|
},
|
|
setInitialVisibility() {
|
|
const isVisible = localStorage.getItem('NodeDesignVisible') === 'true';
|
|
const isPermanent = localStorage.getItem('NodeAlignerIsPermanent') === '1';
|
|
this.isVisible = false;
|
|
this.isPermanent = isPermanent;
|
|
if (isVisible && isPermanent) {
|
|
setTimeout(() => {
|
|
this.show();
|
|
}, 0);
|
|
} else {
|
|
this.buttonContainer.style.visibility = 'hidden';
|
|
this.buttonContainer.style.display = 'none';
|
|
this.isVisible = false;
|
|
}
|
|
this.updateToggleButton();
|
|
},
|
|
show() {
|
|
if (!this.isVisible) {
|
|
this.isClosedByUser = false;
|
|
this.restorePosition();
|
|
this.buttonContainer.style.display = 'flex';
|
|
this.buttonContainer.style.visibility = 'visible';
|
|
this.isVisible = true;
|
|
localStorage.setItem('NodeDesignVisible', 'true');
|
|
this.updateToggleButton();
|
|
}
|
|
},
|
|
hide() {
|
|
if (!this.isPermanent) {
|
|
this.buttonContainer.style.display = 'none';
|
|
this.buttonContainer.style.visibility = 'hidden';
|
|
this.isVisible = false;
|
|
}
|
|
},
|
|
setPosition(x, y) {
|
|
const containerRect = this.buttonContainer.getBoundingClientRect();
|
|
const windowWidth = window.innerWidth;
|
|
const windowHeight = window.innerHeight;
|
|
if (x + containerRect.width > windowWidth) {
|
|
x = windowWidth - containerRect.width;
|
|
}
|
|
if (x < 0) {
|
|
x = 0;
|
|
}
|
|
if (y + containerRect.height > windowHeight) {
|
|
y = windowHeight - containerRect.height;
|
|
}
|
|
if (y < 0) {
|
|
y = 0;
|
|
}
|
|
this.buttonContainer.style.left = `${x}px`;
|
|
this.buttonContainer.style.top = `${y}px`;
|
|
},
|
|
onDragStart(e) {
|
|
if (e.button !== 0) return;
|
|
e.stopPropagation();
|
|
this.isDragging = true;
|
|
this.dragStartX = e.clientX - this.buttonContainer.offsetLeft;
|
|
this.dragStartY = e.clientY - this.buttonContainer.offsetTop;
|
|
},
|
|
onDragging(e) {
|
|
if (!this.isDragging) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const newX = e.clientX - this.dragStartX;
|
|
const newY = e.clientY - this.dragStartY;
|
|
this.buttonContainer.style.left = `${newX}px`;
|
|
this.buttonContainer.style.top = `${newY}px`;
|
|
},
|
|
onDragEnd(e) {
|
|
if (!this.isDragging) return;
|
|
if (e.button !== 0) return;
|
|
e.stopPropagation();
|
|
this.isDragging = false;
|
|
const buttonPosition = {
|
|
left: this.buttonContainer.style.left,
|
|
top: this.buttonContainer.style.top,
|
|
userPositioned: true
|
|
};
|
|
localStorage.setItem('NodeAlignerButtonPosition', JSON.stringify(buttonPosition));
|
|
},
|
|
restorePosition() {
|
|
const savedButtonPosition = JSON.parse(localStorage.getItem('NodeAlignerButtonPosition'));
|
|
if (savedButtonPosition && savedButtonPosition.userPositioned) {
|
|
this.buttonContainer.style.left = savedButtonPosition.left;
|
|
this.buttonContainer.style.top = savedButtonPosition.top;
|
|
} else {
|
|
const windowWidth = window.innerWidth;
|
|
const containerWidth = this.buttonContainer.offsetWidth || 400;
|
|
const containerHeight = this.buttonContainer.offsetHeight || 44;
|
|
const left = Math.max(0, (windowWidth - containerWidth) / 2);
|
|
const top = window.innerHeight - containerHeight - 20;
|
|
this.buttonContainer.style.left = `${left}px`;
|
|
this.buttonContainer.style.top = `${top}px`;
|
|
}
|
|
},
|
|
getSelectedNodes() {
|
|
if (!LGraphCanvas.active_canvas) {
|
|
return [];
|
|
}
|
|
const selectedNodesObj = LGraphCanvas.active_canvas.selected_nodes;
|
|
return selectedNodesObj ? Object.values(selectedNodesObj) : [];
|
|
},
|
|
updateColorPickers(selectedNodes) {
|
|
if (!selectedNodes || selectedNodes.length === 0) return;
|
|
const firstNode = selectedNodes[0];
|
|
if (!firstNode) return;
|
|
this.isUpdatingPickers = true;
|
|
try {
|
|
if (this.colorPicker) {
|
|
const currentColor = normalizeColorForInput(firstNode.color, '#ffffff');
|
|
if (this.colorPicker.value !== currentColor) {
|
|
this.colorPicker.value = currentColor;
|
|
}
|
|
}
|
|
if (this.bgColorPicker) {
|
|
const currentBgColor = normalizeColorForInput(firstNode.bgcolor, '#333333');
|
|
if (this.bgColorPicker.value !== currentBgColor) {
|
|
this.bgColorPicker.value = currentBgColor;
|
|
}
|
|
}
|
|
} finally {
|
|
this.isUpdatingPickers = false;
|
|
}
|
|
},
|
|
createAlignmentAction(nodes, property, getValue, setValue) {
|
|
if (nodes.length === 0) return null;
|
|
const originalStates = {};
|
|
nodes.forEach(node => {
|
|
originalStates[node.id] = {
|
|
pos: [...node.pos],
|
|
size: [...node.size]
|
|
};
|
|
});
|
|
const targetValue = getValue(nodes);
|
|
return {
|
|
undo: () => {
|
|
nodes.forEach(node => {
|
|
const originalState = originalStates[node.id];
|
|
if (originalState) {
|
|
node.pos = [...originalState.pos];
|
|
node.size = [...originalState.size];
|
|
}
|
|
});
|
|
},
|
|
redo: () => {
|
|
nodes.forEach(node => {
|
|
const currentPos = [...node.pos];
|
|
const currentSize = [...node.size];
|
|
const originalState = originalStates[node.id];
|
|
if (property === 'pos') {
|
|
const newValue = setValue.func ? setValue.func(node, targetValue) : targetValue;
|
|
const newPos = [...currentPos];
|
|
newPos[setValue.axis] = newValue;
|
|
node.pos = newPos;
|
|
} else if (property === 'size') {
|
|
const newSize = [...currentSize];
|
|
newSize[setValue.axis] = targetValue;
|
|
node.size = newSize;
|
|
}
|
|
});
|
|
}
|
|
};
|
|
},
|
|
applyAction(action) {
|
|
if (!action) return false;
|
|
try {
|
|
if (typeof action.redo !== 'function') {
|
|
console.warn('Action missing redo method');
|
|
return false;
|
|
}
|
|
action.redo();
|
|
this.addToUndoStack(action);
|
|
try {
|
|
this.updateCanvas();
|
|
} catch (canvasError) {
|
|
console.warn('Error updating canvas after applying action:', canvasError);
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('Error applying action:', error);
|
|
return false;
|
|
}
|
|
},
|
|
_handleAlignmentAction(actionName) {
|
|
const config = this.ALIGNMENT_CONFIG[actionName];
|
|
if (!config) {
|
|
console.warn("Unknown alignment/equalize action:", actionName);
|
|
return false;
|
|
}
|
|
|
|
const selectedNodes = this.getSelectedNodes();
|
|
if (selectedNodes.length === 0) return false;
|
|
|
|
const boundValueFunc = typeof config.valueFunc === 'function' ? config.valueFunc.bind(this) : config.valueFunc;
|
|
|
|
const action = this.createAlignmentAction(
|
|
selectedNodes,
|
|
config.type,
|
|
boundValueFunc,
|
|
{ axis: config.axis, func: config.transformFunc }
|
|
);
|
|
return this.applyAction(action);
|
|
},
|
|
horizontalDistribution() {
|
|
const selectedNodes = this.getSelectedNodes();
|
|
const calcResult = this._calculateDistributedPositions(selectedNodes, 0);
|
|
if (!calcResult) return false;
|
|
|
|
const { targetPositions, originalStates } = calcResult;
|
|
|
|
const action = {
|
|
undo: () => {
|
|
selectedNodes.forEach(node => {
|
|
const originalState = originalStates[node.id];
|
|
if (originalState) {
|
|
node.pos = [...originalState.pos];
|
|
node.size = [...originalState.size];
|
|
}
|
|
});
|
|
},
|
|
redo: () => {
|
|
selectedNodes.forEach(node => {
|
|
const targetPos = targetPositions[node.id];
|
|
if (targetPos) {
|
|
node.pos[0] = Number(targetPos[0]) || 0;
|
|
node.pos[1] = Number(targetPos[1]) || 0;
|
|
}
|
|
const originalState = originalStates[node.id];
|
|
if (originalState && (node.size[0] !== originalState.size[0] || node.size[1] !== originalState.size[1])) {
|
|
node.size = [...originalState.size];
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
return this.applyAction(action);
|
|
},
|
|
verticalDistribution() {
|
|
const selectedNodes = this.getSelectedNodes();
|
|
const calcResult = this._calculateDistributedPositions(selectedNodes, 1);
|
|
if (!calcResult) return false;
|
|
|
|
const { targetPositions, originalStates } = calcResult;
|
|
|
|
const action = {
|
|
undo: () => {
|
|
selectedNodes.forEach(node => {
|
|
const originalState = originalStates[node.id];
|
|
if (originalState) {
|
|
node.pos = [...originalState.pos];
|
|
node.size = [...originalState.size];
|
|
}
|
|
});
|
|
},
|
|
redo: () => {
|
|
selectedNodes.forEach(node => {
|
|
const targetPos = targetPositions[node.id];
|
|
if (targetPos) {
|
|
node.pos[0] = Number(targetPos[0]) || 0;
|
|
node.pos[1] = Number(targetPos[1]) || 0;
|
|
}
|
|
const originalState = originalStates[node.id];
|
|
if (originalState && (node.size[0] !== originalState.size[0] || node.size[1] !== originalState.size[1])) {
|
|
node.size = [...originalState.size];
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
return this.applyAction(action);
|
|
},
|
|
smartAlign() {
|
|
const selectedNodes = this.getSelectedNodes();
|
|
if (selectedNodes.length < 2) return false;
|
|
const originalStates = {};
|
|
selectedNodes.forEach(node => {
|
|
originalStates[node.id] = {
|
|
pos: [...node.pos],
|
|
size: [...node.size]
|
|
};
|
|
});
|
|
const analysisResult = this.analyzeConnectionsBFS(selectedNodes);
|
|
const levels = analysisResult.levels;
|
|
if (!levels || levels.length === 0) {
|
|
console.warn("Smart Align: Could not determine node levels.");
|
|
return false;
|
|
}
|
|
const targetPositions = {};
|
|
const horizontalSpacing = 100;
|
|
const verticalSpacing = 30;
|
|
let currentX = originalStates[levels[0][0].id].pos[0];
|
|
let maxColumnHeight = 0;
|
|
const columnWidths = [];
|
|
levels.forEach((level, levelIndex) => {
|
|
let currentColumnWidth = 0;
|
|
let currentColumnHeight = 0;
|
|
level.forEach((node, nodeIndex) => {
|
|
const originalSize = originalStates[node.id].size;
|
|
currentColumnWidth = Math.max(currentColumnWidth, originalSize[0]);
|
|
currentColumnHeight += originalSize[1] + (nodeIndex > 0 ? verticalSpacing : 0);
|
|
});
|
|
columnWidths[levelIndex] = currentColumnWidth;
|
|
maxColumnHeight = Math.max(maxColumnHeight, currentColumnHeight);
|
|
});
|
|
levels.forEach((level, levelIndex) => {
|
|
level.sort((a, b) => originalStates[a.id].pos[1] - originalStates[b.id].pos[1]);
|
|
const levelHeight = level.reduce((sum, node, idx) => sum + originalStates[node.id].size[1] + (idx > 0 ? verticalSpacing : 0), 0);
|
|
let currentY = originalStates[levels[0][0].id].pos[1] + (maxColumnHeight - levelHeight) / 2;
|
|
level.forEach(node => {
|
|
const originalSize = originalStates[node.id].size;
|
|
const nodeX = currentX + (columnWidths[levelIndex] - originalSize[0]) / 2;
|
|
targetPositions[node.id] = [nodeX, currentY];
|
|
currentY += originalSize[1] + verticalSpacing;
|
|
});
|
|
currentX += columnWidths[levelIndex] + horizontalSpacing;
|
|
});
|
|
const action = {
|
|
undo: () => {
|
|
selectedNodes.forEach(node => {
|
|
const originalState = originalStates[node.id];
|
|
if (originalState) {
|
|
node.pos = [...originalState.pos];
|
|
node.size = [...originalState.size];
|
|
}
|
|
});
|
|
},
|
|
redo: () => {
|
|
selectedNodes.forEach(node => {
|
|
const targetPos = targetPositions[node.id];
|
|
if (targetPos) {
|
|
node.pos = [Number(targetPos[0]) || 0, Number(targetPos[1]) || 0];
|
|
}
|
|
const originalState = originalStates[node.id];
|
|
if (originalState && (node.size[0] !== originalState.size[0] || node.size[1] !== originalState.size[1])) {
|
|
node.size = [...originalState.size];
|
|
}
|
|
});
|
|
}
|
|
};
|
|
return this.applyAction(action);
|
|
},
|
|
analyzeConnectionsBFS(nodes) {
|
|
const graph = {};
|
|
const inDegree = {};
|
|
const nodeMap = {};
|
|
nodes.forEach(node => {
|
|
const nodeId = node.id;
|
|
graph[nodeId] = { node, outputs: [] };
|
|
inDegree[nodeId] = 0;
|
|
nodeMap[nodeId] = node;
|
|
});
|
|
nodes.forEach(node => {
|
|
if (node.inputs) {
|
|
node.inputs.forEach(input => {
|
|
if (input.link !== null) {
|
|
const sourceNode = nodes.find(n => n.outputs && n.outputs.some(o => o.links && o.links.includes(input.link)));
|
|
if (sourceNode) {
|
|
const sourceId = sourceNode.id;
|
|
const destId = node.id;
|
|
if (!graph[sourceId].outputs.includes(destId)) {
|
|
graph[sourceId].outputs.push(destId);
|
|
inDegree[destId]++;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
const queue = [];
|
|
for (const nodeId in inDegree) {
|
|
if (inDegree[nodeId] === 0) {
|
|
queue.push(nodeId);
|
|
}
|
|
}
|
|
const result = [];
|
|
const levels = [];
|
|
let levelIndex = 0;
|
|
while (queue.length > 0) {
|
|
const levelSize = queue.length;
|
|
const currentLevelNodes = [];
|
|
for (let i = 0; i < levelSize; i++) {
|
|
const u = queue.shift();
|
|
const nodeObj = nodeMap[u];
|
|
result.push(nodeObj);
|
|
currentLevelNodes.push(nodeObj);
|
|
if(graph[u]){
|
|
graph[u].outputs.forEach(v => {
|
|
if (inDegree.hasOwnProperty(v)) {
|
|
inDegree[v]--;
|
|
if (inDegree[v] === 0) {
|
|
queue.push(v);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
if (currentLevelNodes.length > 0) {
|
|
levels[levelIndex] = currentLevelNodes;
|
|
levelIndex++;
|
|
}
|
|
}
|
|
if (result.length !== nodes.length) {
|
|
console.warn("Cycle detected or disconnected nodes within selection. Layout might be incomplete.");
|
|
return { sortedNodes: nodes, levels: null };
|
|
}
|
|
return { sortedNodes: result, levels: levels };
|
|
},
|
|
getNodesBoundsInScreenCoords(nodes) {
|
|
if (!nodes || nodes.length === 0 || !LGraphCanvas.active_canvas) {
|
|
console.warn("getNodesBoundsInScreenCoords: No nodes or active canvas found.");
|
|
return { left: 10, top: 10, right: 110, bottom: 110, width: 100, height: 100 };
|
|
}
|
|
const canvas = LGraphCanvas.active_canvas;
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
nodes.forEach(node => {
|
|
const nodeGraphX = node.pos[0];
|
|
const nodeGraphY = node.pos[1];
|
|
const nodeWidth = node.size[0];
|
|
const nodeHeight = node.size[1];
|
|
try {
|
|
const topLeft = canvas.convertOffsetToCanvas([nodeGraphX, nodeGraphY]);
|
|
const bottomRight = canvas.convertOffsetToCanvas([nodeGraphX + nodeWidth, nodeGraphY + nodeHeight]);
|
|
minX = Math.min(minX, topLeft[0]);
|
|
minY = Math.min(minY, topLeft[1]);
|
|
maxX = Math.max(maxX, bottomRight[0]);
|
|
maxY = Math.max(maxY, bottomRight[1]);
|
|
} catch (e) {
|
|
console.error("Error converting node coordinates:", e, node);
|
|
}
|
|
});
|
|
if (minX === Infinity) {
|
|
console.warn("getNodesBoundsInScreenCoords: Could not calculate valid screen coordinates.");
|
|
return { left: 10, top: 10, right: 110, bottom: 110, width: 100, height: 100 };
|
|
}
|
|
return {
|
|
left: minX,
|
|
top: minY,
|
|
right: maxX,
|
|
bottom: maxY,
|
|
width: maxX - minX,
|
|
height: maxY - minY
|
|
};
|
|
},
|
|
updateCanvas() {
|
|
if (!LGraphCanvas.active_canvas) return;
|
|
try {
|
|
const canvas = LGraphCanvas.active_canvas;
|
|
canvas.setDirty(true, true);
|
|
} catch (e) {
|
|
console.warn("Error marking canvas dirty: ", e);
|
|
}
|
|
},
|
|
treeView() {
|
|
const selectedNodes = this.getSelectedNodes();
|
|
if (selectedNodes.length < 2) return false;
|
|
const originalStates = {};
|
|
selectedNodes.forEach(node => {
|
|
originalStates[node.id] = {
|
|
pos: [...node.pos],
|
|
size: [...node.size]
|
|
};
|
|
});
|
|
const rootNode = this.findRootNode(selectedNodes);
|
|
if (!rootNode) {
|
|
console.warn("Tree View: Could not find a root node.");
|
|
return false;
|
|
}
|
|
const targetPositions = {};
|
|
const levels = this.buildSimpleHierarchy(rootNode, selectedNodes);
|
|
const nodeWidth = Math.max(...selectedNodes.map(node => originalStates[node.id].size[0]));
|
|
const nodeHeight = Math.max(...selectedNodes.map(node => originalStates[node.id].size[1]));
|
|
const horizontalSpacing = nodeWidth * 1.5;
|
|
const verticalSpacing = nodeHeight * 1.5;
|
|
const rootOriginalPos = originalStates[rootNode.id].pos;
|
|
levels.forEach((level, levelIndex) => {
|
|
const levelWidth = (level.length - 1) * horizontalSpacing;
|
|
const startX = rootOriginalPos[0];
|
|
level.forEach((node, nodeIndex) => {
|
|
const targetX = startX + (nodeIndex - (level.length - 1) / 2) * horizontalSpacing;
|
|
const targetY = rootOriginalPos[1] + levelIndex * verticalSpacing;
|
|
targetPositions[node.id] = [targetX, targetY];
|
|
});
|
|
});
|
|
const action = {
|
|
undo: () => selectedNodes.forEach(node => {
|
|
const originalState = originalStates[node.id];
|
|
if (originalState) {
|
|
node.pos = [...originalState.pos];
|
|
node.size = [...originalState.size];
|
|
}
|
|
}),
|
|
redo: () => {
|
|
selectedNodes.forEach(node => {
|
|
const targetPos = targetPositions[node.id];
|
|
if (targetPos) {
|
|
node.pos = [Number(targetPos[0]) || 0, Number(targetPos[1]) || 0];
|
|
}
|
|
});
|
|
}
|
|
};
|
|
return this.applyAction(action);
|
|
},
|
|
placeAdjacent(direction = 'right', gap = 20) {
|
|
const selected = this.getSelectedNodes();
|
|
if (selected.length < 2) return false;
|
|
|
|
const axis = direction === 'right' ? 0 : 1;
|
|
const otherAxis = axis === 0 ? 1 : 0;
|
|
|
|
const originalStates = {};
|
|
selected.forEach(n => {
|
|
originalStates[n.id] = { pos: [...n.pos], size: [...n.size] };
|
|
});
|
|
|
|
// Prefer connection-based order when possible
|
|
const analysis = this.analyzeConnectionsBFS(selected);
|
|
const flow = analysis && analysis.sortedNodes && analysis.sortedNodes.length === selected.length
|
|
? analysis.sortedNodes
|
|
: [...selected].sort((a, b) => originalStates[a.id].pos[axis] - originalStates[b.id].pos[axis]);
|
|
|
|
const anchor = flow[0];
|
|
const rest = flow.slice(1);
|
|
|
|
const targetPositions = {};
|
|
let currentCoord = originalStates[anchor.id].pos[axis] + originalStates[anchor.id].size[axis] + gap;
|
|
rest.forEach(n => {
|
|
const newPos = [...originalStates[n.id].pos];
|
|
newPos[axis] = currentCoord;
|
|
newPos[otherAxis] = originalStates[anchor.id].pos[otherAxis];
|
|
targetPositions[n.id] = newPos;
|
|
currentCoord += originalStates[n.id].size[axis] + gap;
|
|
});
|
|
|
|
const action = {
|
|
undo: () => {
|
|
selected.forEach(n => {
|
|
const st = originalStates[n.id];
|
|
n.pos = [...st.pos];
|
|
n.size = [...st.size];
|
|
});
|
|
},
|
|
redo: () => {
|
|
rest.forEach(n => {
|
|
const pos = targetPositions[n.id];
|
|
if (pos) n.pos = [Number(pos[0]) || 0, Number(pos[1]) || 0];
|
|
});
|
|
}
|
|
};
|
|
|
|
return this.applyAction(action);
|
|
},
|
|
findRootNode(nodes) {
|
|
return nodes.find(node => !node.inputs || node.inputs.every(input => input.link === null));
|
|
},
|
|
buildSimpleHierarchy(rootNode, allNodes) {
|
|
const levels = [[rootNode]];
|
|
const visited = new Set([rootNode.id]);
|
|
let currentLevel = [rootNode];
|
|
while (currentLevel.length > 0) {
|
|
const nextLevel = [];
|
|
for (const node of currentLevel) {
|
|
const children = allNodes.filter(n =>
|
|
!visited.has(n.id) && n.inputs && n.inputs.some(input =>
|
|
input.link !== null && node.outputs && node.outputs.some(output =>
|
|
output.links && output.links.includes(input.link)
|
|
)
|
|
)
|
|
);
|
|
children.forEach(child => visited.add(child.id));
|
|
nextLevel.push(...children);
|
|
}
|
|
if (nextLevel.length > 0) {
|
|
levels.push(nextLevel);
|
|
}
|
|
currentLevel = nextLevel;
|
|
}
|
|
return levels;
|
|
},
|
|
createSingleColorPicker(id, title, changeHandler) {
|
|
const picker = document.createElement('input');
|
|
picker.type = 'color';
|
|
picker.id = id;
|
|
picker.classList.add('nd-color-picker');
|
|
picker.title = title;
|
|
picker.addEventListener('input', changeHandler);
|
|
picker.addEventListener('change', changeHandler);
|
|
return picker;
|
|
},
|
|
applyNodeColorChange(property, color) {
|
|
if (this.isUpdatingPickers) return;
|
|
const selectedNodes = this.getSelectedNodes();
|
|
if (selectedNodes.length > 0) {
|
|
selectedNodes.forEach(node => {
|
|
const oldColor = node.color;
|
|
const oldBgColor = node.bgcolor;
|
|
const nextColor = property === 'color' ? color : node.color;
|
|
const nextBgColor = property === 'bgcolor' ? color : node.bgcolor;
|
|
|
|
if (typeof node.setColorOption === 'function') {
|
|
node.setColorOption({
|
|
color: nextColor,
|
|
bgcolor: nextBgColor
|
|
});
|
|
} else {
|
|
node[property] = color;
|
|
}
|
|
|
|
// Newer ComfyUI frontend mirrors node color into Vue node data through this event.
|
|
if (node.graph?.trigger) {
|
|
node.graph.trigger('node:property:changed', {
|
|
nodeId: node.id,
|
|
property: 'color',
|
|
oldValue: oldColor,
|
|
newValue: node.color
|
|
});
|
|
node.graph.trigger('node:property:changed', {
|
|
nodeId: node.id,
|
|
property: 'bgcolor',
|
|
oldValue: oldBgColor,
|
|
newValue: node.bgcolor
|
|
});
|
|
}
|
|
});
|
|
if (LGraphCanvas.active_canvas) {
|
|
LGraphCanvas.active_canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
},
|
|
onNodeColorChange(event) {
|
|
const color = event.target.value;
|
|
|
|
const validColor = color.length === 4 ?
|
|
`#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` :
|
|
color;
|
|
this.applyNodeColorChange('color', validColor);
|
|
},
|
|
onNodeBgColorChange(event) {
|
|
const color = event.target.value;
|
|
|
|
const validColor = color.length === 4 ?
|
|
`#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` :
|
|
color;
|
|
this.applyNodeColorChange('bgcolor', validColor);
|
|
},
|
|
pollForCanvas() {
|
|
const canvas = document.querySelector('canvas#graph-canvas');
|
|
if (canvas) {
|
|
ButtonManager.init();
|
|
canvas.addEventListener('click', (event) => {
|
|
const selectedNodes = this.getSelectedNodes();
|
|
if (!this.isPermanent) {
|
|
if (!this.isClosedByUser && selectedNodes.length >= 2) {
|
|
this.show();
|
|
const nodesBounds = this.getNodesBoundsInScreenCoords(selectedNodes);
|
|
const containerWidth = this.buttonContainer.offsetWidth || 400;
|
|
const containerHeight = this.buttonContainer.offsetHeight || 44;
|
|
|
|
let left = nodesBounds.left + (nodesBounds.width / 2) - (containerWidth / 2);
|
|
let top = nodesBounds.bottom + 50;
|
|
|
|
this.setPosition(left, top);
|
|
|
|
} else {
|
|
this.hide();
|
|
}
|
|
} else {
|
|
if (!this.isClosedByUser && !this.isVisible) {
|
|
this.show();
|
|
}
|
|
}
|
|
this.updateColorPickers(selectedNodes);
|
|
});
|
|
} else if (attempts < maxAttempts) {
|
|
attempts++;
|
|
setTimeout(this.pollForCanvas.bind(this), 1000);
|
|
}
|
|
},
|
|
setupContextMenu() {
|
|
const onContextMenu = LGraphCanvas.prototype.getCanvasMenuOptions;
|
|
LGraphCanvas.prototype.getCanvasMenuOptions = function() {
|
|
const options = onContextMenu.call(this);
|
|
options.push(null);
|
|
options.push({
|
|
content: ButtonManager.isVisible ? "Hide Node Design Tool" : "Show Node Design Tool",
|
|
callback: () => {
|
|
if (ButtonManager.isVisible) {
|
|
ButtonManager.close();
|
|
} else {
|
|
ButtonManager.show();
|
|
ButtonManager.isPermanent = true;
|
|
localStorage.setItem('NodeAlignerIsPermanent', '1');
|
|
ButtonManager.updateToggleButton();
|
|
}
|
|
}
|
|
});
|
|
return options;
|
|
};
|
|
},
|
|
close() {
|
|
this.buttonContainer.style.display = 'none';
|
|
this.buttonContainer.style.visibility = 'hidden';
|
|
this.isVisible = false;
|
|
localStorage.setItem('NodeDesignVisible', 'false');
|
|
localStorage.removeItem('NodeAlignerButtonPosition');
|
|
this.isClosedByUser = true;
|
|
this.isPermanent = false;
|
|
this.updateToggleButton();
|
|
},
|
|
|
|
ALIGNMENT_CONFIG: {
|
|
alignLeft: { type: 'pos', axis: 0, valueFunc: nodes => Math.min(...nodes.map(n => n.pos[0])), transformFunc: (n, v) => v },
|
|
alignRight: { type: 'pos', axis: 0, valueFunc: nodes => Math.max(...nodes.map(n => n.pos[0] + n.size[0])), transformFunc: (n, v) => v - n.size[0] },
|
|
alignTop: { type: 'pos', axis: 1, valueFunc: nodes => Math.min(...nodes.map(n => n.pos[1])), transformFunc: (n, v) => v },
|
|
alignBottom: { type: 'pos', axis: 1, valueFunc: nodes => Math.max(...nodes.map(n => n.pos[1] + n.size[1])), transformFunc: (n, v) => v - n.size[1] },
|
|
alignCenterHorizontally: { type: 'pos', axis: 1, valueFunc: function(nodes) { return this.calculateCenterInGroup(nodes, 1); }, transformFunc: (n, v) => v - n.size[1] / 2 },
|
|
alignCenterVertically: { type: 'pos', axis: 0, valueFunc: function(nodes) { return this.calculateCenterInGroup(nodes, 0); }, transformFunc: (n, v) => v - n.size[0] / 2 },
|
|
equalWidth: { type: 'size', axis: 0, valueFunc: nodes => Math.max(...nodes.map(n => n.size[0])) },
|
|
equalHeight: { type: 'size', axis: 1, valueFunc: nodes => Math.max(...nodes.map(n => n.size[1])) }
|
|
},
|
|
calculateCenterInGroup(group, axis) {
|
|
const minCoord = Math.min(...group.map(node => node.pos[axis]));
|
|
const maxCoord = Math.max(...group.map(node => node.pos[axis] + node.size[axis]));
|
|
return (minCoord + maxCoord) / 2;
|
|
},
|
|
_calculateDistributedPositions(nodes, axis) {
|
|
if (nodes.length < 2) return null;
|
|
|
|
const originalStates = {};
|
|
nodes.forEach(node => {
|
|
originalStates[node.id] = {
|
|
pos: [...node.pos],
|
|
size: [...node.size]
|
|
};
|
|
});
|
|
|
|
const analysisResult = this.analyzeConnectionsBFS(nodes);
|
|
let flowSortedNodes = analysisResult.sortedNodes;
|
|
const otherAxis = axis === 0 ? 1 : 0;
|
|
|
|
if (!flowSortedNodes || flowSortedNodes.length !== nodes.length) {
|
|
console.warn(`Distribution axis ${axis}: Could not determine flow order. Falling back to position-based sorting.`);
|
|
flowSortedNodes = [...nodes].sort((a, b) => a.pos[axis] - b.pos[axis]);
|
|
}
|
|
|
|
const minCoord = Math.min(...flowSortedNodes.map(node => originalStates[node.id].pos[axis]));
|
|
const maxCoord = Math.max(...flowSortedNodes.map(node => originalStates[node.id].pos[axis] + originalStates[node.id].size[axis]));
|
|
const totalAvailableSpace = maxCoord - minCoord;
|
|
const totalNodesSize = flowSortedNodes.reduce((sum, node) => sum + originalStates[node.id].size[axis], 0);
|
|
|
|
const nodeCount = flowSortedNodes.length;
|
|
const totalGaps = nodeCount - 1;
|
|
const equalGap = totalGaps > 0
|
|
? Math.max(0, (totalAvailableSpace - totalNodesSize) / totalGaps)
|
|
: 0;
|
|
|
|
const targetPositions = {};
|
|
let currentCoord = minCoord;
|
|
|
|
flowSortedNodes.forEach(node => {
|
|
const originalPos = originalStates[node.id].pos;
|
|
const targetPos = [...originalPos];
|
|
targetPos[axis] = currentCoord;
|
|
targetPositions[node.id] = targetPos;
|
|
currentCoord += originalStates[node.id].size[axis] + equalGap;
|
|
});
|
|
|
|
return { targetPositions, originalStates, flowSortedNodes };
|
|
},
|
|
};let attempts = 0;
|
|
const maxAttempts = 10;
|
|
ButtonManager.pollForCanvas();
|