import { app } from "../../scripts/app.js"; const alignBottomSvg = ``; const alignCenterHorizontallySvg = ``; const alignCenterVerticallySvg = ``; const alignLeftSvg = ``; const alignRightSvg = ``; const alignTopSvg = ``; const horizontalDistributionSvg = ``; const verticalDistributionSvg = ``; const equalWidthSvg = `` const equalWidth2Svg = `` const equalHeightSvg = `` const equalHeight2Svg = `` const ButtonManager = { isInitialized: false, // 检查是否已经初始化 buttonContainer: null, // 保存按钮容器 contextMenu: null, // 右键菜单 isPermanent: true, // 是否为驻留按钮 initialX: 0, // 初始位置X initialY: 0, // 初始位置Y dragStartX: 0, // 拖拽开始时的X dragStartY: 0, // 拖拽开始时的Y isDragging: false, // 检查是否正在拖拽 hasShownTooltip: false, // 检查是否已经显示过提示 isShow: true, // 是否显示按钮 // 初始化按钮 init() { if (this.isInitialized) return; this.boundOnDragStart = this.onDragStart.bind(this); this.boundOnDragging = this.onDragging.bind(this); this.boundOnDragEnd = this.onDragEnd.bind(this); this.boundOnSelectionChange = this.onSelectionChange.bind(this); this.boundOnDocumentPointerDown = (event) => { if (!event.composedPath().includes(this.buttonContainer)) { this.contextMenu.style.display = 'none'; } }; // Create button container this.buttonContainer = document.createElement('div'); this.buttonContainer.id = 'alignment-buttons'; // Create shadow root const shadow = this.buttonContainer.attachShadow({ mode: 'open' }); // Create main wrapper to maintain structure const wrapper = document.createElement('div'); wrapper.id = 'alignment-buttons-wrapper'; wrapper.style.cssText = 'display: flex; width: 100%; height: 100%;'; // Create style element for shadow DOM const style = document.createElement('style'); style.textContent = ` #alignment-buttons { z-index: 999999999; } :host { position: fixed; top: 20px; right: 10px; left: unset; bottom: unset; display: block; background: #2b2b2b; padding: 4px; border-radius: 4px; z-index: 9999999999; width: 290px; height: 32px; } #alignment-buttons-wrapper { display: flex; white-space: nowrap; } .custom-button { width: 25px; height: 25px; background-size: contain; background-repeat: no-repeat; background-position: center; background-color: #2b2b2b; border: none; cursor: pointer; padding: 4px; border-radius: 4px; transition: background-color 0.3s ease; } .custom-button:hover { background-color: #555555; } .divider { width: 3.2px; height: 15px; background-color: #1e1e1e; margin: 5px 4px; border-radius: 9px; cursor: grab; } .divider:active { cursor: grabbing; } #tooltip { position: absolute; bottom: -20px; right: 0; background: #333; color: white; padding: 5px 10px; border-radius: 6px; display: none; z-index: 1001; white-space: nowrap; font-size: 12px; } #context-menu { position: fixed; background: #2b2b2b; border-radius: 4px; padding: 5px; z-index: 10000; display: none; } #context-menu button { display: block; width: 100%; padding: 5px 10px; background: none; border: none; color: white; cursor: pointer; text-align: left; border-radius: 4px; overflow: hidden; } #context-menu button:hover { background-color: #555555; } .custom-button svg { width: 100%; height: 100%; } `; // Append style and wrapper to shadow DOM shadow.appendChild(style); shadow.appendChild(wrapper); document.body.appendChild(this.buttonContainer); this.restorePosition(); // Create buttons and assign functionality const buttons = this.getButtons(); buttons.forEach(btn => { if (btn.type === 'divider') { const divider = document.createElement('div'); divider.classList.add('divider'); divider.addEventListener('mousedown', this.boundOnDragStart); wrapper.appendChild(divider); } else { const button = document.createElement('button'); button.id = btn.id; button.type = 'button'; button.title = btn.title; button.setAttribute('aria-label', btn.title); button.classList.add('custom-button'); button.innerHTML = btn.svg; button.addEventListener('click', (event) => { this.runAction(btn.action, event); event.stopPropagation(); }); wrapper.appendChild(button); } }); // Create tooltip const tooltip = document.createElement('div'); tooltip.id = 'tooltip'; tooltip.textContent = '右键可选按钮呈现模式'; wrapper.appendChild(tooltip); // Create context menu const contextMenu = document.createElement('div'); contextMenu.id = 'context-menu'; contextMenu.innerHTML = ` `; wrapper.appendChild(contextMenu); this.contextMenu = contextMenu; // Bind context menu events this.buttonContainer.addEventListener('contextmenu', (event) => { event.preventDefault(); contextMenu.style.display = 'block'; contextMenu.style.left = `${event.clientX}px`; contextMenu.style.top = `${event.clientY}px`; }); // Click handlers for context menu contextMenu.querySelector('#pin-mode').addEventListener('click', () => { contextMenu.style.display = 'none'; this.isPermanent = true; localStorage.setItem('NodeAlignerIsPermanent', '1'); this.restorePosition(); this.show(); }); contextMenu.querySelector('#toggle-on-select').addEventListener('click', () => { this.hide(); contextMenu.style.display = 'none'; this.isPermanent = false; localStorage.setItem('NodeAlignerIsPermanent', '0'); }); // Handle tooltip display if (!this.hasShownTooltip) { this.buttonContainer.addEventListener('mouseenter', () => { if (!this.hasShownTooltip) { this.tooltipTimer = setTimeout(() => { tooltip.style.display = 'block'; this.hasShownTooltip = true; }, 1000); } }); this.buttonContainer.addEventListener('mouseleave', () => { clearTimeout(this.tooltipTimer); tooltip.style.display = 'none'; }); } // Initialize visibility state let isPermanent = localStorage.getItem('NodeAlignerIsPermanent'); if (isPermanent) { this.isPermanent = isPermanent === '1'; this.isPermanent ? this.show() : this.hide(); } else { this.show(); } document.addEventListener('pointerup', this.boundOnSelectionChange, true); document.addEventListener('pointerdown', this.boundOnDocumentPointerDown, true); this.isInitialized = true; }, // 获取按钮配置 getButtons() { return [ /* 左对齐 */ { id: 'alignLeft', title: '左对齐(按列)/ 未选择时重置视图', svg: alignLeftSvg, action: this.alignLeft.bind(this) }, /* 竖向居中对齐 */ { id: 'alignCenterVertically', title: '水平居中(按列)', svg: alignCenterVerticallySvg, action: this.alignCenterVertically.bind(this) }, /* 右对齐 */ { id: 'alignRight', title: '右对齐(按列)', svg: alignRightSvg, action: this.alignRight.bind(this) }, // 分割线 { type: 'divider' }, /* 顶部对齐 */ { id: 'alignTop', title: '顶部对齐(按行)', svg: alignTopSvg, action: this.alignTop.bind(this) }, /* 横向居中对齐 */ { id: 'alignCenterHorizontally', title: '垂直居中(按行)', svg: alignCenterHorizontallySvg, action: this.alignCenterHorizontally.bind(this) }, /* 底部对齐 */ { id: 'alignBottom', title: '底部对齐(按行)', svg: alignBottomSvg, action: this.alignBottom.bind(this) }, // 分割线 { type: 'divider' }, /* 横向分布 */ { id: 'horizontalDistribution', title: '水平分布(按行)', svg: horizontalDistributionSvg, action: this.horizontalDistribution.bind(this) }, /* 竖向分布 */ { id: 'verticalDistribution', title: '垂直分布(按列)', svg: verticalDistributionSvg, action: this.verticalDistribution.bind(this) }, // 分割线 { type: 'divider' }, /* 等宽 */ { id: 'equalWidth', title: '等宽(最大值)', svg: equalWidthSvg, action: this.equalWidth.bind(this) }, { id: 'equalMinWidth', title: '等宽(最小值)', svg: equalWidth2Svg, action: this.equalMinWidth.bind(this) }, /* 等高 */ { id: 'equalHeight', title: '等高(最大值)', svg: equalHeightSvg, action: this.equalHeight.bind(this) }, { id: 'equalMinHeight', title: '等高(最小值)', svg: equalHeight2Svg, action: this.equalMinHeight.bind(this) }, ]; }, // 显示按钮 show() { this.isShow = true; this.buttonContainer.style.display = 'flex'; // 显示按钮容器 }, // 隐藏按钮 hide() { this.isShow = false; this.buttonContainer.style.display = 'none'; // 隐藏按钮容器 }, setPosition(x, y) { const rect = this.buttonContainer.getBoundingClientRect(); this.buttonContainer.style.right = 'unset'; this.buttonContainer.style.bottom = 'unset'; this.buttonContainer.style.left = `${Math.max(0, Math.min(x, window.innerWidth - rect.width))}px`; this.buttonContainer.style.top = `${Math.max(0, Math.min(y, window.innerHeight - rect.height))}px`; }, onSelectionChange(event) { if (this.isPermanent || event.composedPath().includes(this.buttonContainer)) return; const canvas = app.canvas?.canvas; if (!canvas?.parentElement?.contains(event.target)) return; if (event.target !== canvas && !event.target.closest?.('.lg-node')) return; cancelAnimationFrame(this.selectionFrame); this.selectionFrame = requestAnimationFrame(() => { if (this.isPermanent) return; if (this.getSelectedNodes().length >= 2) { this.show(); this.setPosition(event.clientX - 240, event.clientY - 20); } else { this.hide(); } }); }, // 开始拖拽 onDragStart(e) { if (e.button !== 0 || !e.target.classList.contains('divider') || this.isDragging) return; e.preventDefault(); this.isDragging = true; this.initialX = e.clientX; this.initialY = e.clientY; const rect = this.buttonContainer.getBoundingClientRect(); this.dragStartX = rect.left; this.dragStartY = rect.top; document.addEventListener('mousemove', this.boundOnDragging); document.addEventListener('mouseup', this.boundOnDragEnd); }, // 拖拽中 onDragging(e) { if (this.isDragging) { const deltaX = e.clientX - this.initialX; const deltaY = e.clientY - this.initialY; this.setPosition(this.dragStartX + deltaX, this.dragStartY + deltaY); } }, // 结束拖拽 onDragEnd() { if (!this.isDragging) return; this.isDragging = false; // 移除事件监听器 document.removeEventListener('mousemove', this.boundOnDragging); document.removeEventListener('mouseup', this.boundOnDragEnd); if (!this.isPermanent) return; // 获取按钮容器的位置信息 const rect = this.buttonContainer.getBoundingClientRect(); const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; // 计算距离四个边的距离 const distanceToTop = rect.top; const distanceToBottom = windowHeight - rect.bottom; const distanceToLeft = rect.left; const distanceToRight = windowWidth - rect.right; // 选择两个最近的边 let top = 'unset'; let bottom = 'unset'; let left = 'unset'; let right = 'unset'; if (distanceToTop < distanceToBottom) { top = `${distanceToTop}px`; } else { bottom = `${distanceToBottom}px`; } if (distanceToLeft < distanceToRight) { left = `${distanceToLeft}px`; } else { right = `${distanceToRight}px`; } // 更新样式使其根据距离四边的情况固定 this.buttonContainer.style.top = top; this.buttonContainer.style.bottom = bottom; this.buttonContainer.style.left = left; this.buttonContainer.style.right = right; // 保存位置到 localStorage localStorage.setItem('NodeAlignerButtonContainerPosition', JSON.stringify({ top, left, right, bottom })); }, destroy() { document.removeEventListener('mousemove', this.boundOnDragging); document.removeEventListener('mouseup', this.boundOnDragEnd); document.removeEventListener('pointerup', this.boundOnSelectionChange, true); document.removeEventListener('pointerdown', this.boundOnDocumentPointerDown, true); cancelAnimationFrame(this.selectionFrame); clearTimeout(this.tooltipTimer); this.buttonContainer?.remove(); this.buttonContainer = null; this.contextMenu = null; this.isDragging = false; this.isInitialized = false; }, // 恢复位置 restorePosition() { try { const savedPosition = JSON.parse(localStorage.getItem('NodeAlignerButtonContainerPosition')); if (savedPosition && typeof savedPosition === 'object') { this.buttonContainer.style.top = savedPosition.top || '20px'; this.buttonContainer.style.bottom = savedPosition.bottom || 'unset'; this.buttonContainer.style.left = savedPosition.left || 'unset'; this.buttonContainer.style.right = savedPosition.right || '20px'; } else { this.setDefaultPosition(); } } catch (e) { console.warn('Failed to parse saved position:', e); this.setDefaultPosition(); } // ===== 在这里检测是否超出屏幕边界 ===== const containerRect = this.buttonContainer.getBoundingClientRect(); const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; if ( containerRect.left < 0 || containerRect.top < 0 || containerRect.right > windowWidth || containerRect.bottom > windowHeight ) { console.log('NodeAligner位置已超出屏幕范围,重置为默认位置'); // 调用setDefaultPosition重置位置 this.setDefaultPosition(); // 保存默认位置到 localStorage,确保下次进入不会越界 localStorage.setItem('NodeAlignerButtonContainerPosition', JSON.stringify({ top: this.buttonContainer.style.top, left: this.buttonContainer.style.left, right: this.buttonContainer.style.right, bottom: this.buttonContainer.style.bottom })); } }, setDefaultPosition() { this.buttonContainer.style.top = '20px'; this.buttonContainer.style.right = '20px'; this.buttonContainer.style.bottom = 'unset'; this.buttonContainer.style.left = 'unset'; }, // 获取当前选中的节点 getSelectedNodes() { const canvas = app.canvas; if (!canvas) return []; if (canvas.selectedItems) { return [...canvas.selectedItems].filter(item => item instanceof window.LGraphNode); } return Object.values(canvas.selected_nodes || {}); }, runAction(action, event) { const canvas = app.canvas; if (!canvas) return; const graph = canvas.graph; const changesNodes = this.getSelectedNodes().length > 0; if (changesNodes) { if (!graph) return; graph.beforeChange(); canvas.emitBeforeChange?.(); } try { action.call(this, event); } finally { if (changesNodes) { graph.afterChange(); canvas.emitAfterChange?.(); } canvas.setDirty(true, true); } }, // Nodes 2.0 的控件最小尺寸可能大于工作流里保存的 size。 getNodeSize(node, axis) { return (node.renderingSize ?? node.size)[axis]; }, compareNodes(a, b, axis) { return a.pos[axis] - b.pos[axis] || a.pos[1 - axis] - b.pos[1 - axis] || String(a.id).localeCompare(String(b.id)); }, median(values) { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }, getTrackTolerance(nodes, axis) { return this.median(nodes.map(node => this.getNodeSize(node, axis))) / 4; }, getReferenceNode(group, axis) { return group.reduce((first, node) => this.compareNodes(node, first, axis) < 0 ? node : first); }, // 同一行/列保留共同的投影区间,避免通过相邻节点链式合并。 getOverlappingGroups(nodes, axis) { const groups = []; const maximumSpan = this.getTrackTolerance(nodes, axis) * 4; let commonEnd = -Infinity; for (const node of [...nodes].sort((a, b) => this.compareNodes(a, b, axis))) { const group = groups[groups.length - 1]; const start = node.pos[axis]; const end = start + this.getNodeSize(node, axis); if (group && start < commonEnd && start - group[0].pos[axis] <= maximumSpan) { group.push(node); commonEnd = Math.min(commonEnd, end); } else { groups.push([node]); commonEnd = end; } } return groups; }, // 矮节点错位后投影可能不重叠,明显的行/列间断层仍能提供分层证据。 getGapGroups(nodes, axis) { const sorted = [...nodes].sort((a, b) => this.compareNodes(a, b, axis)); const gaps = sorted.slice(1).map((node, index) => node.pos[axis] - sorted[index].pos[axis]) .filter(gap => gap > 0); const typicalGap = this.median(gaps); if (!typicalGap || Math.max(...gaps) <= typicalGap * 2) return []; const groups = []; for (let i = 0; i < sorted.length; i++) { if (i === 0 || sorted[i].pos[axis] - sorted[i - 1].pos[axis] > typicalGap * 1.5) { groups.push([sorted[i]]); } else { groups[groups.length - 1].push(sorted[i]); } } return groups; }, // 跨行/列做有序匹配;允许缺格和新增列,不把短行向左压缩。 matchGridTracks(groups, axis, nodes) { const seed = groups.reduce((longest, group) => group.length > longest.length ? group : longest); let tracks = [...seed].sort((a, b) => this.compareNodes(a, b, axis)).map(node => [node]); const typicalSize = this.getTrackTolerance(nodes, axis) * 4; const gapCost = 0.5; for (const group of groups) { if (group === seed) continue; const ordered = [...group].sort((a, b) => this.compareNodes(a, b, axis)); const references = tracks.map(track => this.getReferenceNode(track, 1 - axis)); const gaps = references.slice(1).map((node, index) => node.pos[axis] - references[index].pos[axis]) .filter(gap => gap > 0); const scale = Math.max(1, typicalSize, this.median(gaps)); const path = Array.from({ length: tracks.length + 1 }, () => new Uint8Array(ordered.length + 1)); let previous = Float64Array.from({ length: ordered.length + 1 }, (_, index) => index * gapCost); let current = new Float64Array(ordered.length + 1); for (let j = 1; j <= ordered.length; j++) path[0][j] = 3; for (let i = 1; i <= tracks.length; i++) { current[0] = i * gapCost; path[i][0] = 2; for (let j = 1; j <= ordered.length; j++) { const match = previous[j - 1] + Math.abs(references[i - 1].pos[axis] - ordered[j - 1].pos[axis]) / scale; const missing = previous[j] + gapCost; const inserted = current[j - 1] + gapCost; if (match <= missing && match <= inserted) { current[j] = match; path[i][j] = 1; } else if (missing <= inserted) { current[j] = missing; path[i][j] = 2; } else { current[j] = inserted; path[i][j] = 3; } } [previous, current] = [current, previous]; } const matched = []; let i = tracks.length; let j = ordered.length; while (i || j) { const direction = path[i][j]; if (direction === 1) matched.push([...tracks[--i], ordered[--j]]); else if (direction === 2) matched.push(tracks[--i]); else matched.push([ordered[--j]]); } tracks = matched.reverse(); } return tracks; }, getNodeGrid(nodes) { if (nodes.length < 4) return null; let best = null; let bestCount = 0; let bestSpread = Infinity; for (const axis of [1, 0]) { const crossTolerance = this.getTrackTolerance(nodes, 1 - axis); const scale = Math.max(1, this.getTrackTolerance(nodes, axis) * 4); for (const groups of [this.getNodeGroups(nodes, axis), this.getOverlappingGroups(nodes, axis), this.getGapGroups(nodes, axis)]) { const repeated = groups.filter(group => group.length > 1 && Math.max(...group.map(node => node.pos[1 - axis])) - Math.min(...group.map(node => node.pos[1 - axis])) > crossTolerance); if (repeated.length < 2) continue; const count = repeated.reduce((sum, group) => sum + group.length, 0); const spread = repeated.reduce((sum, group) => sum + Math.min(...[0, 0.5, 1].map(anchor => { const coordinates = group.map(node => node.pos[axis] + this.getNodeSize(node, axis) * anchor); return Math.max(...coordinates) - Math.min(...coordinates); })) / scale, 0); if (count > bestCount || (count === bestCount && spread < bestSpread)) { best = { axis, groups }; bestCount = count; bestSpread = spread; } } } if (!best) return null; const matched = this.matchGridTracks(best.groups, 1 - best.axis, nodes); return best.axis === 1 ? { rows: best.groups, columns: matched } : { rows: matched, columns: best.groups }; }, // 用起点、中心或终点识别已经成形的行列,避免节点宽高不同造成拆组。 getNodeGroups(nodes, axis, preferredAnchor = 0) { if (nodes.length < 2) return [nodes]; const tolerance = this.getTrackTolerance(nodes, axis); let bestGroups = null; let bestCount = 0; let bestTrackCount = Infinity; let bestSpread = Infinity; let exactGroups = null; for (const anchor of new Set([preferredAnchor, 0, 0.5, 1])) { const coordinate = node => node.pos[axis] + this.getNodeSize(node, axis) * anchor; const sorted = [...nodes].sort((a, b) => coordinate(a) - coordinate(b) || this.compareNodes(a, b, 1 - axis)); for (const threshold of [1e-6, tolerance]) { const groups = []; for (const node of sorted) { const group = groups[groups.length - 1]; // 限制整组跨度,不让相邻节点通过链式合并串成一组。 if (group && coordinate(node) - coordinate(group[0]) <= threshold) { group.push(node); } else { groups.push([node]); } } const repeated = groups.filter(group => group.length > 1); const count = repeated.reduce((sum, group) => sum + group.length, 0); const spread = repeated.reduce((sum, group) => sum + coordinate(group[group.length - 1]) - coordinate(group[0]), 0); // 完整的共同边线优先,防止再次点击时把相近但已对齐的列合并。 if (threshold === 1e-6 && count === nodes.length && (!exactGroups || repeated.length < exactGroups.length)) exactGroups = groups; if (threshold === tolerance && (count > bestCount || (count === bestCount && (repeated.length < bestTrackCount || (repeated.length === bestTrackCount && spread < bestSpread))))) { bestGroups = groups; bestCount = count; bestTrackCount = repeated.length; bestSpread = spread; } } } // 只有一条成形轨道时,将偏移的孤立节点一起整理(例如三齐一歪)。 return bestTrackCount >= 2 && bestGroups ? (exactGroups ?? bestGroups) : [nodes]; }, alignNodes(axis, anchor) { const nodes = this.getSelectedNodes(); if (nodes.length === 0) return; const grid = this.getNodeGrid(nodes); const groups = grid ? (axis === 0 ? grid.columns : grid.rows) : this.getNodeGroups(nodes, axis, anchor); for (const group of groups) { const minimum = Math.min(...group.map(node => node.pos[axis])); const maximum = Math.max(...group.map(node => node.pos[axis] + this.getNodeSize(node, axis))); const target = grid && anchor === 0 ? this.getReferenceNode(group, 1 - axis).pos[axis] : minimum + (maximum - minimum) * anchor; group.forEach(node => { node.pos[axis] = target - this.getNodeSize(node, axis) * anchor; }); } }, // 左对齐;未选择节点时恢复预设视图。 alignLeft() { if (this.getSelectedNodes().length === 0) { const canvas = app.canvas; if (canvas) { canvas.ds.offset = [80, 140]; canvas.ds.scale = 1; } } else { this.alignNodes(0, 0); } }, alignRight() { this.alignNodes(0, 1); }, alignTop() { this.alignNodes(1, 0); }, alignBottom() { this.alignNodes(1, 1); }, alignCenterHorizontally() { this.alignNodes(1, 0.5); }, alignCenterVertically() { this.alignNodes(0, 0.5); }, distributeNodes(axis) { const nodes = this.getSelectedNodes(); const grid = this.getNodeGrid(nodes); const groups = grid ? (axis === 0 ? grid.rows : grid.columns) : this.getNodeGroups(nodes, 1 - axis); for (const group of groups) { if (group.length < 2) continue; group.sort((a, b) => this.compareNodes(a, b, axis)); const start = group[0].pos[axis]; const end = Math.max(...group.map(node => node.pos[axis] + this.getNodeSize(node, axis))); const totalSize = group.reduce((sum, node) => sum + this.getNodeSize(node, axis), 0); const spacing = Math.max(20, (end - start - totalSize) / (group.length - 1)); let current = start; group.forEach(node => { node.pos[axis] = current; current += this.getNodeSize(node, axis) + spacing; }); } }, horizontalDistribution() { this.distributeNodes(0); }, verticalDistribution() { this.distributeNodes(1); }, // 等宽 对齐最大值 equalWidth() { this.equalSize(0); }, // 等宽 对齐最小值 equalMinWidth() { this.equalSize(0, Math.min); }, // 等高 equalHeight() { this.equalSize(1); },// 等高 对齐最小值 equalMinHeight() { this.equalSize(1, Math.min); }, // 通用的节点尺寸调整 equalSize(axis, comparator = Math.max) { const nodes = this.getSelectedNodes(); if (nodes.length > 0) { // 使用传入的比较函数(默认为 Math.max) const sizeValues = nodes.map(node => node.size[axis]); const targetSize = comparator(...sizeValues); // 将尺寸调整为最近的5的倍数 const roundedTargetSize = Math.round(targetSize / 5) * 5; nodes.forEach(node => { const size = [...node.size]; size[axis] = roundedTargetSize; node.setSize(size); }); } } }; app.registerExtension({ name: "Comfy.NodeAligner", setup() { ButtonManager.init(); } });