Files
MechaBabyAi-ComfyUI-MechaBa…/web/js/nodeSearch.js
T
Mecha Baby 68b124d263 v1.2.0: Add golden flash highlight, easy use support, and bug fixes
- Add golden flash highlight effect when jumping to nodes
- Add support for easy getNode/setNode jumping (comfyui-easy-use)
- Migrate to new ComfyUI Context Menu API
- Auto-focus on search input when opening dialog
- Remove unused 'available node types' feature
- Code optimization and bug fixes
- Update all README files (EN, ZH, JA, KO, RU)
2026-01-07 01:31:45 +08:00

647 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ComfyUI MechaBaby Node Search Extension
*
* 功能说明:
* 1. 节点名称搜索定位 - 支持节点标题和类型的搜索
* 2. 节点属性搜索 - 搜索控件名称、控件值、属性名称、属性值
* 3. 快捷键支持 - Ctrl+F 快速打开搜索对话框
* 4. 键盘导航 - 支持上下箭头键选择,Enter 跳转,Esc 关闭
*
* 技术实现:
* - 使用 ComfyUI Extension API (app.registerExtension)
* - 访问 app.graph._nodes 获取所有节点
* - 使用 app.canvas.centerOnNode() 实现节点定位
* - 创建自定义对话框 UI 显示搜索结果
*
* 依赖:
* - ComfyUI 核心 API (app, app.graph, app.canvas)
* - LiteGraph API (LGraphCanvas)
*
* @file nodeSearch.js
* @author MechaBaby
* @version 1.2.0
*/
import { app } from "../../../scripts/app.js";
// 模块级变量,用于在不同 hook 之间共享
var nodeSearchState = {
searchDialog: null,
searchResults: [],
currentResultIndex: -1,
keyboardHandlerBound: false,
openSearchDialog: null // 将在 setup 中设置
};
app.registerExtension({
name: "MechaBaby.NodeSearch",
// 新的 Context Menu API hook
getCanvasMenuItems: function() {
return [
null, // separator
{
content: "🔍 搜索节点 (Ctrl+F)",
callback: function() {
if (nodeSearchState.openSearchDialog) {
nodeSearchState.openSearchDialog();
}
}
}
];
},
setup: function() {
var searchDialog = nodeSearchState.searchDialog;
var searchResults = nodeSearchState.searchResults;
var currentResultIndex = nodeSearchState.currentResultIndex;
var keyboardHandlerBound = nodeSearchState.keyboardHandlerBound;
/**
* 搜索节点(包括名称、属性名称、属性值)
* @param {string} keyword - 搜索关键词
*/
function searchNodes(keyword) {
if (!keyword || keyword.trim() === "") {
return [];
}
var keywordLower = keyword.toLowerCase().trim();
var results = [];
// 搜索工作流中的节点
if (app.graph && app.graph._nodes) {
app.graph._nodes.forEach(function(node) {
// 使用 try-catch 保护,避免红色报错节点导致搜索中断
try {
// 检查节点是否有效(红色报错节点可能缺少某些属性)
if (!node) return;
const matches = [];
let nodeTitle = "";
// 安全地获取节点标题
try {
nodeTitle = node.getTitle ? node.getTitle() : (node.title || node.type || "");
} catch (e) {
nodeTitle = node.type || node.title || "未知节点";
}
// 1. 搜索节点标题
if (nodeTitle && nodeTitle.toLowerCase().includes(keywordLower)) {
matches.push({
type: 'node_title',
name: '节点标题',
value: nodeTitle,
display: '节点: ' + nodeTitle
});
}
// 2. 搜索节点类型
if (node.type && node.type.toLowerCase().includes(keywordLower)) {
matches.push({
type: 'node_type',
name: '节点类型',
value: node.type,
display: '类型: ' + node.type
});
}
// 3. 搜索控件名称和值(安全访问)
try {
if (node.widgets && Array.isArray(node.widgets)) {
node.widgets.forEach(function(widget, index) {
try {
if (!widget) return;
const widgetName = widget.name || '';
const widgetValue = (node.widgets_values && node.widgets_values[index] !== undefined)
? node.widgets_values[index]
: (widget.value !== undefined ? widget.value : '');
// 搜索控件名称
if (widgetName && widgetName.toLowerCase().includes(keywordLower)) {
matches.push({
type: 'widget_name',
name: widgetName,
value: widgetValue,
display: '控件: ' + widgetName + ' = ' + String(widgetValue).substring(0, 50)
});
}
// 搜索控件值(转换为字符串)
const valueStr = String(widgetValue);
if (valueStr && valueStr.toLowerCase().includes(keywordLower) && widgetName) {
matches.push({
type: 'widget_value',
name: widgetName,
value: widgetValue,
display: '值: ' + widgetName + ' = ' + valueStr.substring(0, 50)
});
}
} catch (widgetError) {
// 单个控件出错不影响其他控件
console.debug("[MechaBaby NodeSearch] 搜索控件时出错:", widgetError);
}
});
}
} catch (widgetsError) {
// 控件访问出错,继续搜索其他属性
console.debug("[MechaBaby NodeSearch] 访问节点控件时出错:", widgetsError);
}
// 4. 搜索节点属性(安全访问)
try {
if (node.properties && typeof node.properties === 'object') {
Object.keys(node.properties).forEach(function(propName) {
try {
var propValue = node.properties[propName];
var propValueStr = String(propValue);
// 搜索属性名称
if (propName && propName.toLowerCase().includes(keywordLower)) {
matches.push({
type: 'property_name',
name: propName,
value: propValue,
display: '属性: ' + propName + ' = ' + propValueStr.substring(0, 50)
});
}
// 搜索属性值
if (propValueStr && propValueStr.toLowerCase().includes(keywordLower)) {
matches.push({
type: 'property_value',
name: propName,
value: propValue,
display: '属性值: ' + propName + ' = ' + propValueStr.substring(0, 50)
});
}
} catch (propError) {
// 单个属性出错不影响其他属性
console.debug("[MechaBaby NodeSearch] 搜索属性时出错:", propError);
}
});
}
} catch (propertiesError) {
// 属性访问出错,继续处理
console.debug("[MechaBaby NodeSearch] 访问节点属性时出错:", propertiesError);
}
// 如果有匹配,添加到结果
if (matches.length > 0) {
results.push({
node: node,
nodeTitle: nodeTitle,
nodeType: node.type || "未知类型",
nodeId: node.id,
matches: matches,
matchCount: matches.length,
isAvailableNode: false // 工作流中的节点
});
}
} catch (nodeError) {
// 节点处理出错,记录但继续搜索其他节点
const nodeTypeStr = (node && node.type) ? node.type : "未知";
console.debug("[MechaBaby NodeSearch] 搜索节点时出错(可能是未安装的节点):", nodeError, nodeTypeStr);
// 即使节点出错,也尝试搜索节点类型(如果可用)
if (node && node.type) {
try {
const nodeType = node.type;
if (nodeType.toLowerCase().includes(keywordLower)) {
results.push({
node: node,
nodeTitle: nodeType + " (错误节点)",
nodeType: nodeType,
nodeId: node.id,
matches: [{
type: 'node_type',
name: '节点类型',
value: nodeType,
display: '类型: ' + nodeType + ' (节点可能未正确加载)'
}],
matchCount: 1,
isAvailableNode: false,
hasError: true // 标记为有错误的节点
});
}
} catch (e) {
// 完全无法处理,跳过
}
}
}
});
}
return results;
}
/**
* 跳转到节点并高亮
*/
function jumpToNode(node, matchIndex) {
if (matchIndex === undefined) {
matchIndex = 0;
}
if (!node) return;
// 跳转到节点
app.canvas.centerOnNode(node);
// 选中节点
app.canvas.selectNode(node);
// 添加金黄色闪烁高亮效果
highlightNode(node);
}
/**
* 高亮闪烁节点(金黄色效果)
*/
function highlightNode(node) {
if (!node) return;
// 保存原始颜色
var originalColor = node.color;
var originalBgColor = node.bgcolor;
// 金黄色高亮颜色
var highlightColor = "#FFD700";
var highlightBgColor = "#4a3d00";
var flashCount = 0;
var maxFlashes = 6; // 闪烁3次(6次切换)
var flashInterval = 150; // 每次闪烁间隔150ms
function flash() {
if (flashCount >= maxFlashes) {
// 恢复原始颜色
node.color = originalColor;
node.bgcolor = originalBgColor;
app.canvas.setDirty(true, true);
return;
}
if (flashCount % 2 === 0) {
// 高亮
node.color = highlightColor;
node.bgcolor = highlightBgColor;
} else {
// 恢复
node.color = originalColor;
node.bgcolor = originalBgColor;
}
app.canvas.setDirty(true, true);
flashCount++;
setTimeout(flash, flashInterval);
}
// 开始闪烁
flash();
}
/**
* 创建搜索对话框
*/
function createSearchDialog() {
if (searchDialog) {
return searchDialog;
}
const dialog = document.createElement('div');
dialog.id = 'mechababy-node-search-dialog';
dialog.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #2a2a2a;
border: 2px solid #4a4a4a;
border-radius: 8px;
padding: 20px;
z-index: 10000;
min-width: 500px;
max-width: 700px;
max-height: 600px;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
font-family: 'Microsoft YaHei', 'SimHei', Arial, sans-serif;
`;
// 标题
const title = document.createElement('div');
title.textContent = '🔍 节点搜索';
title.style.cssText = `
font-size: 18px;
font-weight: bold;
color: #e0e0e0;
margin-bottom: 15px;
border-bottom: 1px solid #4a4a4a;
padding-bottom: 10px;
`;
dialog.appendChild(title);
// 搜索输入框
const inputContainer = document.createElement('div');
inputContainer.style.cssText = 'margin-bottom: 15px;';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = '输入关键词搜索节点名称、属性名称或值...';
input.style.cssText = `
width: 100%;
padding: 10px;
background: #1a1a1a;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: #e0e0e0;
font-size: 14px;
box-sizing: border-box;
`;
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
if (searchResults.length > 0 && currentResultIndex >= 0) {
var result = searchResults[currentResultIndex];
jumpToNode(result.node);
closeDialog();
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (currentResultIndex < searchResults.length - 1) {
currentResultIndex++;
updateResultsList();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (currentResultIndex > 0) {
currentResultIndex--;
updateResultsList();
}
} else if (e.key === 'Escape') {
closeDialog();
}
});
input.addEventListener('input', function(e) {
var keyword = e.target.value;
searchResults = searchNodes(keyword);
currentResultIndex = searchResults.length > 0 ? 0 : -1;
updateResultsList();
});
inputContainer.appendChild(input);
dialog.appendChild(inputContainer);
// 结果列表容器
const resultsContainer = document.createElement('div');
resultsContainer.id = 'search-results-container';
resultsContainer.style.cssText = `
flex: 1;
overflow-y: auto;
max-height: 400px;
border: 1px solid #4a4a4a;
border-radius: 4px;
background: #1a1a1a;
`;
dialog.appendChild(resultsContainer);
// 提示信息
const info = document.createElement('div');
info.id = 'search-info';
info.style.cssText = `
margin-top: 10px;
font-size: 12px;
color: #888;
text-align: center;
`;
dialog.appendChild(info);
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.textContent = '关闭 (Esc)';
closeBtn.style.cssText = `
margin-top: 10px;
padding: 8px 16px;
background: #4a4a4a;
border: none;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
`;
closeBtn.addEventListener('click', closeDialog);
closeBtn.addEventListener('mouseenter', function() {
closeBtn.style.background = '#5a5a5a';
});
closeBtn.addEventListener('mouseleave', function() {
closeBtn.style.background = '#4a4a4a';
});
dialog.appendChild(closeBtn);
// 更新结果列表
function updateResultsList() {
const container = resultsContainer;
container.innerHTML = '';
if (searchResults.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = '未找到匹配的节点';
emptyMsg.style.cssText = `
padding: 20px;
text-align: center;
color: #888;
`;
container.appendChild(emptyMsg);
info.textContent = '';
return;
}
var totalMatches = 0;
for (var i = 0; i < searchResults.length; i++) {
totalMatches += searchResults[i].matchCount;
}
info.textContent = '找到 ' + searchResults.length + ' 个节点,' + totalMatches + ' 个匹配项';
for (var idx = 0; idx < searchResults.length; idx++) {
(function(index) {
var result = searchResults[index];
var item = document.createElement('div');
item.style.cssText = 'padding: 12px; border-bottom: 1px solid #3a3a3a; cursor: pointer; background: ' + (index === currentResultIndex ? '#3a3a3a' : 'transparent') + '; transition: background 0.2s;';
item.addEventListener('mouseenter', function() {
if (index !== currentResultIndex) {
item.style.background = '#333';
}
});
item.addEventListener('mouseleave', function() {
if (index !== currentResultIndex) {
item.style.background = 'transparent';
}
});
item.addEventListener('click', function() {
if (result.hasError) {
// 错误节点,尝试跳转
if (result.node) {
try {
jumpToNode(result.node);
} catch (e) {
alert('节点 "' + result.nodeTitle + '" 可能未正确加载。请检查扩展是否已安装。');
}
}
} else {
// 跳转到节点
jumpToNode(result.node);
closeDialog();
}
});
// 节点标题
var title = document.createElement('div');
var statusBadge = result.hasError ? ' [错误节点]' : '';
title.textContent = result.nodeTitle + statusBadge + ' (' + result.matchCount + ' 个匹配)';
title.style.cssText = 'font-weight: bold; color: ' + (result.hasError ? '#ff4a4a' : '#4a9eff') + '; margin-bottom: 5px; font-size: 14px;';
item.appendChild(title);
// 节点类型
var type = document.createElement('div');
var typeText = '类型: ' + result.nodeType;
if (result.hasError) {
typeText += ' | ⚠️ 节点可能未正确加载';
}
type.textContent = typeText;
type.style.cssText = 'color: ' + (result.hasError ? '#ff8888' : '#888') + '; font-size: 12px; margin-bottom: 8px;';
item.appendChild(type);
// 匹配项列表(最多显示3个)
var matchesList = document.createElement('div');
var displayMatches = result.matches.slice(0, 3);
for (var j = 0; j < displayMatches.length; j++) {
var match = displayMatches[j];
var matchItem = document.createElement('div');
matchItem.textContent = ' • ' + match.display;
matchItem.style.cssText = 'color: #aaa; font-size: 12px; margin-left: 10px; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;';
matchesList.appendChild(matchItem);
}
if (result.matches.length > 3) {
var more = document.createElement('div');
more.textContent = ' ... 还有 ' + (result.matches.length - 3) + ' 个匹配项';
more.style.cssText = 'color: #666; font-size: 11px; margin-left: 10px; font-style: italic;';
matchesList.appendChild(more);
}
item.appendChild(matchesList);
container.appendChild(item);
})(idx);
}
}
function closeDialog() {
if (dialog.parentNode) {
dialog.parentNode.removeChild(dialog);
}
searchDialog = null;
searchResults = [];
currentResultIndex = -1;
}
// 点击外部关闭
dialog.addEventListener('click', function(e) {
if (e.target === dialog) {
closeDialog();
}
});
// 初始化
updateResultsList();
return dialog;
}
/**
* 打开搜索对话框
*/
function openSearchDialog() {
if (!searchDialog) {
searchDialog = createSearchDialog();
document.body.appendChild(searchDialog);
}
// 每次打开都聚焦输入框
setTimeout(function() {
var input = searchDialog.querySelector('input');
if (input) {
input.focus();
input.select();
}
}, 50);
}
// 保存函数引用到模块级变量,供 getCanvasMenuItems hook 使用
nodeSearchState.openSearchDialog = openSearchDialog;
/**
* 绑定快捷键监听器
*/
function bindKeyboardShortcut() {
if (keyboardHandlerBound) return;
var handler = function(e) {
// 检查是否是 Ctrl+F 或 Cmd+F
if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F')) {
// 如果输入框有焦点,不拦截(让用户可以在搜索框中输入)
var activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
// 如果焦点在搜索对话框的输入框中,不拦截
if (searchDialog && searchDialog.contains(activeElement)) {
return;
}
}
// 阻止默认行为(浏览器搜索)
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
openSearchDialog();
return false;
}
};
// 在多个地方绑定,确保能捕获到
// 1. document 级别(捕获阶段,优先级最高)
document.addEventListener('keydown', handler, true);
// 2. window 级别
window.addEventListener('keydown', handler, true);
// 3. 画布级别(如果可用)
if (app.canvas) {
if (app.canvas.canvas) {
app.canvas.canvas.addEventListener('keydown', handler, true);
}
// 等待画布完全加载
setTimeout(function() {
if (app.canvas && app.canvas.canvas) {
app.canvas.canvas.addEventListener('keydown', handler, true);
}
}, 500);
}
keyboardHandlerBound = true;
}
// 立即绑定
bindKeyboardShortcut();
// 延迟再次绑定,确保在所有扩展加载后
setTimeout(function() {
bindKeyboardShortcut();
}, 1000);
// 右键菜单通过 getCanvasMenuItems hook 添加(新的 Context Menu API)
console.log("[MechaBaby NodeSearch] 扩展已加载 - 按 Ctrl+F 打开搜索");
}
});