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();