/** * 节点挂载服务 (NodeMountService) * 统一管理小助手在不同渲染模式下的创建和挂载 * * 支持两种渲染模式: * - litegraph.js: 传统Canvas渲染 + DOM Widget覆盖层 * - Vue node2.0: 纯Vue组件渲染 */ import { app } from "../../../../scripts/app.js"; import { logger } from '../utils/logger.js'; import { EventManager } from '../utils/eventManager.js'; // ---渲染模式枚举--- export const RENDER_MODE = { LITEGRAPH: 'litegraph', VUE_NODES: 'vue_nodes', UNKNOWN: 'unknown' }; /** * 节点挂载服务类 * 提供渲染模式检测、容器查找、挂载管理等功能 */ class NodeMountService { constructor() { // 当前渲染模式 this.currentMode = RENDER_MODE.UNKNOWN; // 模式切换回调列表 this._modeChangeCallbacks = []; // 是否已初始化 this._initialized = false; // 设置监听清理函数 this._cleanupFunctions = []; // 模式检测缓存,避免频繁检测 this._modeCache = null; this._modeCacheTime = 0; this._modeCacheTTL = 1000; // 缓存有效期1秒 // 挂载观察者映射 { nodeId: observer } this._observers = new Map(); // ---模式切换互斥锁--- this._modeSwitching = false; // 待处理的模式切换请求 this._pendingModeChange = null; } // ---初始化与生命周期--- /** * 初始化服务并设置模式监听 */ initialize() { if (this._initialized) { logger.debug('[NodeMountService] 已初始化,跳过'); return; } // 检测初始渲染模式 this.currentMode = this.detectRenderMode(); // 设置模式变更监听 this._setupModeWatcher(); this._initialized = true; logger.log(`[NodeMountService] 初始化完成 | 渲染模式: ${this.currentMode}`); } /** * 清理服务资源 */ cleanup() { // 清理所有观察者 this._observers.forEach(observer => observer.disconnect()); this._observers.clear(); // 执行所有清理函数 this._cleanupFunctions.forEach(fn => { try { if (typeof fn === 'function') fn(); } catch (e) { logger.debug(`[NodeMountService] 清理函数执行失败: ${e.message}`); } }); this._cleanupFunctions = []; this._modeChangeCallbacks = []; this._initialized = false; this._modeCache = null; logger.debug('[NodeMountService] 资源清理完成'); } // ---节点类型检测工具--- /** * 检查节点是否为使用comfy-markdown的节点 * 包括 Note、MarkdownNote、PreviewTextNode 等 * @param {object} node - 节点对象 * @returns {boolean} */ _isMarkdownNode(node) { if (!node || !node.type) return false; // 已知的使用comfy-markdown的节点类型 const markdownNodeTypes = ['Note', 'MarkdownNote', 'PreviewAny', 'PreviewTextNode']; if (markdownNodeTypes.includes(node.type)) { return true; } // 检查节点类型名称是否包含相关关键词 const typeLower = node.type.toLowerCase(); return typeLower.includes('markdown') || (typeLower.includes('preview') && typeLower.includes('text')); } /** * 检查节点是否为子图节点 (Subgraph) * 子图节点的类型名为 UUID 格式 * @param {object} node - 节点对象 * @returns {boolean} */ _isSubgraphNode(node) { if (!node || !node.type) return false; // UUID 格式:8-4-4-4-12 字符 return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(node.type); } // ---渲染模式检测--- /** * 检测当前渲染模式 * 【优化】只使用最可靠的 LiteGraph.vueNodesMode 全局标志 * @param {boolean} forceRefresh - 是否强制刷新缓存 * @returns {string} 渲染模式枚举值 */ detectRenderMode(forceRefresh = false) { // 检查缓存 const now = Date.now(); if (!forceRefresh && this._modeCache && (now - this._modeCacheTime) < this._modeCacheTTL) { return this._modeCache; } // 【简化】只使用最可靠的全局标志 const mode = (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true) ? RENDER_MODE.VUE_NODES : RENDER_MODE.LITEGRAPH; // 更新缓存 this._modeCache = mode; this._modeCacheTime = now; return mode; } /** * 检查是否为 Vue node2.0 模式 * @returns {boolean} */ isVueNodesMode() { return this.detectRenderMode() === RENDER_MODE.VUE_NODES; } /** * 检查是否为 litegraph.js 模式 * @returns {boolean} */ isLitegraphMode() { return this.detectRenderMode() === RENDER_MODE.LITEGRAPH; } // ---模式切换监听--- /** * 注册模式切换回调 * @param {Function} callback - 回调函数,接收 (newMode, oldMode) 参数 */ onModeChange(callback) { if (typeof callback === 'function') { this._modeChangeCallbacks.push(callback); } } /** * 移除模式切换回调 * @param {Function} callback - 要移除的回调函数 */ offModeChange(callback) { const index = this._modeChangeCallbacks.indexOf(callback); if (index > -1) { this._modeChangeCallbacks.splice(index, 1); } } /** * 检查是否正在进行模式切换 * @returns {boolean} */ isModeSwitching() { return this._modeSwitching; } /** * 触发模式切换事件(带锁保护) * @param {string} newMode - 新模式 * @param {string} oldMode - 旧模式 */ _triggerModeChange(newMode, oldMode) { // 如果已在切换中,记录待处理请求并返回 if (this._modeSwitching) { this._pendingModeChange = { newMode, oldMode }; logger.debug('[NodeMountService] 模式切换已加锁,请求入队列'); return; } this._modeSwitching = true; logger.log(`[NodeMountService] 渲染模式切换 | ${oldMode} -> ${newMode}`); // 清除缓存 this._modeCache = null; // 执行所有回调,等待异步回调完成 Promise.all(this._modeChangeCallbacks.map(async callback => { try { await callback(newMode, oldMode); } catch (e) { logger.error(`[NodeMountService] 模式切换回调执行失败: ${e.message}`); } })).finally(() => { this._modeSwitching = false; // 处理待处理的请求 if (this._pendingModeChange) { const pending = this._pendingModeChange; this._pendingModeChange = null; this._triggerModeChange(pending.newMode, pending.oldMode); } }); } /** * 设置模式变更监听器 * 使用 ComfyUI 官方事件监听渲染模式切换 */ _setupModeWatcher() { // 记录状态用于比对 let lastMode = this.currentMode; // 统一的状态变更检查函数 const checkModeChange = () => { const currentMode = this.detectRenderMode(true); if (currentMode !== lastMode) { const oldMode = lastMode; lastMode = currentMode; this.currentMode = currentMode; this._triggerModeChange(currentMode, oldMode); } }; try { if (app.ui?.settings) { // 监听 ComfyUI 官方 CustomEvent const eventName = 'Comfy.VueNodes.Enabled.change'; const handleEvent = () => { // 延迟 50ms 确保 LiteGraph.vueNodesMode 已完成全局同步 setTimeout(checkModeChange, 50); }; app.ui.settings.addEventListener(eventName, handleEvent); this._cleanupFunctions.push(() => { app.ui.settings.removeEventListener(eventName, handleEvent); }); logger.debug('[NodeMountService] 渲染模式监听器已就绪 (事件监听模式)'); } else { // 兜底策略: 如果 app.ui.settings 尚未就绪,保留低频轮询 const intervalId = setInterval(checkModeChange, 2000); this._cleanupFunctions.push(() => clearInterval(intervalId)); logger.debug('[NodeMountService] app.ui.settings 未就绪,启动低频轮询兜底 (2s)'); } } catch (e) { logger.debug(`[NodeMountService] 模式监听器设置失败: ${e.message}`); } } // ---容器查找--- /** * 为节点的输入控件查找挂载容器 * @param {object} node - LiteGraph节点对象 * @param {object} widget - 输入控件对象 * @returns {object|null} 容器信息对象或null */ findMountContainer(node, widget) { if (!node || !widget) { logger.debug('[NodeMountService] findMountContainer: 参数无效'); return null; } const mode = this.detectRenderMode(); if (mode === RENDER_MODE.VUE_NODES) { return this._findVueNodeContainer(node, widget); } else { return this._findDomWidgetContainer(node, widget); } } /** * Vue node2.0 模式下查找容器 * @param {object} node - LiteGraph节点对象 * @param {object} widget - 输入控件对象 * @returns {object|null} 容器信息 */ /** * 判断一个 widget 是否应该被渲染为 Textarea * @param {object} widget */ _isTextareaWidget(widget) { if (!widget) return false; let targetWidget = widget; if (typeof widget.resolveDeepest === 'function') { try { const deepest = widget.resolveDeepest(); if (deepest && deepest.widget) targetWidget = deepest.widget; } catch (e) {} } // 1. 明确的 customtext 类型 或 string if (targetWidget.type === 'customtext' || targetWidget.type === 'string') return true; // 2. STRING 类型且 multiline: true if (targetWidget.type === 'STRING' && targetWidget.options?.multiline) return true; // 3. 已经绑定了 textarea 元素 if (targetWidget.element && targetWidget.element.tagName === 'TEXTAREA') return true; return false; } /** * Vue node2.0 模式下查找容器 * @param {object} node - LiteGraph节点对象 * @param {object} widget - 输入控件对象 * @returns {object|null} 容器信息 */ _findVueNodeContainer(node, widget) { try { let targetWidget = widget; if (typeof widget.resolveDeepest === 'function') { try { const deepest = widget.resolveDeepest(); if (deepest && deepest.widget) targetWidget = deepest.widget; } catch (e) {} } // 查找带有 data-node-id 的 Vue 节点容器 const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`); if (!nodeContainer) { return null; } // 获取widget名称用于查找对应的textarea const widgetName = targetWidget.name || targetWidget.id || widget.name; let textarea = null; // 识别节点类型 const isSubgraph = this._isSubgraphNode(node); const isMarkdown = this._isMarkdownNode(node); // --- 策略1: 优先使用 widget.inputEl (如果已绑定且为 PrimeVue 组件) --- if (targetWidget.inputEl && targetWidget.inputEl.tagName === 'TEXTAREA') { if (nodeContainer.contains(targetWidget.inputEl)) { textarea = targetWidget.inputEl; } } // --- 策略2: 计算索引位置匹配 (子图节点多输入框场景必用) --- // 适用于多个同类型输入框的情况 (如 Subgraph, CLIPTextEncodeSDXL 等) if (!textarea && node.widgets) { // 1. 计算当前 widget 在所有 Textarea 类 widget 中的索引 let targetIndex = -1; let currentIndex = 0; for (const w of node.widgets) { // Skip hidden widgets because they are not rendered in the DOM if (w.hidden || w.type === 'hidden') { continue; } if (this._isTextareaWidget(w)) { // 尝试解包动态代理 (如 Subgraph 的 PromotedWidgetView) 以比对底层真实引用 let wInternal = w; if (typeof w.resolveDeepest === 'function') { try { const deepest = w.resolveDeepest(); if (deepest && deepest.widget) wInternal = deepest.widget; } catch (e) {} } if (w === widget || wInternal === targetWidget) { targetIndex = currentIndex; break; } // 支持 SubgraphNode 代理控件(利用核心来源标识严格比对,可彻底解决重名文本框冲突问题) else if ( w.sourceNodeId && widget.sourceNodeId && w.sourceWidgetName && widget.sourceWidgetName && w.sourceNodeId === widget.sourceNodeId && w.sourceWidgetName === widget.sourceWidgetName ) { targetIndex = currentIndex; break; } // 支持其他动态代理控件 // 且无法通过解析到底层引用的情况 // 通过名称精确匹配。如果名字存在重复,且无法解析到底层组件,这里会记录第一个匹配到的。 else if (targetIndex === -1 && w.name && widget.name && w.name === widget.name) { // 暂存匹配到的索引,但不要 break,以防后面出现严格引用比对成功的对象 targetIndex = currentIndex; } currentIndex++; } } if (targetIndex !== -1) { // 2. 获取 DOM 中所有的 PrimeVue textarea(优先)或普通 textarea // 【修正】必须保证查找到的是真正与 widget 对应的 textarea // 有可能子图中有些 widget 被隐藏并未渲染,会导致 DOM 中的 textarea 数量 // 少于 node.widgets 里收集到的数量。 const primeTextareas = Array.from(nodeContainer.querySelectorAll('textarea.p-textarea')); const textareas = primeTextareas.length > 0 ? primeTextareas : Array.from(nodeContainer.querySelectorAll('textarea')); // 3. 按索引匹配 if (targetIndex < textareas.length) { textarea = textareas[targetIndex]; logger.debugSample(() => `[NodeMountService] Vue模式: 索引匹配成功 [${targetIndex}] | Widget: ${widgetName} | 子图: ${isSubgraph}`); } } } // --- 策略3: 标签/Placeholder 模糊匹配 (兼容旧逻辑作为补充) --- if (!textarea) { const textareas = nodeContainer.querySelectorAll('textarea'); const searchName = widgetName.toLowerCase().replace(/_/g, ' '); // snake_case -> space separated for (const ta of textareas) { //检查 placeholder const placeholder = (ta.getAttribute('placeholder') || '').toLowerCase(); // 检查 aria-label const ariaLabel = (ta.getAttribute('aria-label') || '').toLowerCase(); // 检查父级 label const label = ta.closest('label')?.textContent?.toLowerCase() || ''; // 检查前置 label (Vue 浮动标签结构) const floatLabel = ta.parentElement?.querySelector('label')?.textContent?.toLowerCase() || ''; if (placeholder.includes(searchName) || ariaLabel.includes(searchName) || label.includes(searchName) || floatLabel.includes(searchName)) { textarea = ta; // logger.debug(`[NodeMountService] Vue模式: 模糊匹配成功 | Widget: ${widgetName}`); break; } } } // --- 策略4: 最后的兜底 (仅当只有一个 textarea 时才敢用) --- if (!textarea) { const textareas = nodeContainer.querySelectorAll('textarea'); if (textareas.length === 1) { textarea = textareas[0]; } } if (!textarea) { // 对于使用comfy-markdown的节点(Note/MarkdownNote/PreviewTextNode等),返回nodeContainer作为容器但textarea为null if (this._isMarkdownNode(node)) { return { container: nodeContainer, textarea: null, // 标记需要进一步查找 nodeContainer: nodeContainer, mode: RENDER_MODE.VUE_NODES, widgetName: widgetName, isNoteNode: true }; } return null; } // 找到textarea的父容器作为挂载点 // 优先找 floatlabel 容器,否则找父级 const mountContainer = textarea.closest('.p-floatlabel, [class*="float"]') || textarea.parentElement; return { container: mountContainer, textarea: textarea, nodeContainer: nodeContainer, mode: RENDER_MODE.VUE_NODES, widgetName: widgetName, isSubgraph: isSubgraph, // 标记是否为子图节点 isNoteNode: isMarkdown // 标记是否为 Markdown 类节点 }; } catch (e) { logger.error(`[NodeMountService] Vue容器查找失败: ${e.message}`); return null; } } /** * litegraph.js 模式下查找容器 * @param {object} node - LiteGraph节点对象 * @param {object} widget - 输入控件对象 * @returns {object|null} 容器信息 */ _findDomWidgetContainer(node, widget) { try { let targetWidget = widget; if (typeof widget.resolveDeepest === 'function') { try { const deepest = widget.resolveDeepest(); if (deepest && deepest.widget) targetWidget = deepest.widget; } catch (e) {} } const inputEl = targetWidget.inputEl || targetWidget.element; if (!inputEl) { logger.debug('[NodeMountService] Litegraph模式: 输入元素不存在'); return null; } // 向上查找 dom-widget 容器 let parent = inputEl.parentElement; let domWidgetContainer = null; while (parent) { if (parent.classList?.contains('dom-widget')) { domWidgetContainer = parent; break; } parent = parent.parentElement; } if (!domWidgetContainer) { logger.debug(`[NodeMountService] Litegraph模式: 未找到dom-widget容器 | 节点ID: ${node.id}`); return null; } return { container: domWidgetContainer, textarea: inputEl, mode: RENDER_MODE.LITEGRAPH, widgetName: targetWidget.name || targetWidget.id }; } catch (e) { logger.error(`[NodeMountService] dom-widget容器查找失败: ${e.message}`); return null; } } // ---图像节点容器查找--- /** * 为图像节点查找挂载容器(用于ImageCaption) * @param {object} node - LiteGraph节点对象 * @returns {object|null} 容器信息 */ findImageNodeContainer(node) { if (!node) return null; const mode = this.detectRenderMode(); if (mode === RENDER_MODE.VUE_NODES) { return this._findVueImageNodeContainer(node); } else { // litegraph模式下,图像小助手使用fixed定位,不需要容器 return { container: document.body, mode: RENDER_MODE.LITEGRAPH, useFixedPositioning: true }; } } /** * Vue node2.0 模式下查找图像节点容器 * @param {object} node - LiteGraph节点对象 * @returns {object|null} 容器信息 */ _findVueImageNodeContainer(node) { try { const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`); if (!nodeContainer) { logger.debug(`[NodeMountService] Vue模式: 未找到图像节点容器 | ID: ${node.id}`); return null; } return { container: nodeContainer, mode: RENDER_MODE.VUE_NODES, useFixedPositioning: false }; } catch (e) { logger.error(`[NodeMountService] Vue图像节点容器查找失败: ${e.message}`); return null; } } // ---挂载辅助方法--- /** * 将小助手元素挂载到容器 * @param {HTMLElement} assistantElement - 小助手DOM元素 * @param {object} containerInfo - findMountContainer返回的容器信息 * @param {object} options - 挂载选项 * @returns {boolean} 是否挂载成功 */ mountAssistant(assistantElement, containerInfo, options = {}) { if (!assistantElement || !containerInfo?.container) { logger.debug('[NodeMountService] mountAssistant: 参数无效'); return false; } try { const { container, mode } = containerInfo; const { position = 'bottom-right', offset = { x: 4, y: 4 } } = options; if (mode === RENDER_MODE.VUE_NODES) { // Vue node2.0 模式:使用相对定位 assistantElement.style.position = 'absolute'; assistantElement.style.zIndex = '10'; if (position === 'bottom-right') { assistantElement.style.right = `${offset.x}px`; assistantElement.style.bottom = `${offset.y}px`; assistantElement.style.left = 'auto'; assistantElement.style.top = 'auto'; } else if (position === 'bottom-left') { assistantElement.style.left = `${offset.x}px`; assistantElement.style.bottom = `${offset.y}px`; assistantElement.style.right = 'auto'; assistantElement.style.top = 'auto'; } // 确保容器有相对定位 const containerPosition = window.getComputedStyle(container).position; if (containerPosition === 'static') { container.style.position = 'relative'; } container.appendChild(assistantElement); } else { // litegraph.js 模式:使用绝对定位(在dom-widget内) assistantElement.style.position = 'absolute'; assistantElement.style.right = `${offset.x}px`; assistantElement.style.bottom = `${offset.y}px`; assistantElement.style.height = '20px'; assistantElement.style.minHeight = '20px'; container.appendChild(assistantElement); } // 触发回流确保样式生效 void assistantElement.offsetWidth; return true; } catch (e) { logger.error(`[NodeMountService] 挂载失败: ${e.message}`); return false; } } /** * 等待元素出现 (使用 MutationObserver) * 替代轮询,实现几乎零延迟的响应 * @param {HTMLElement} parent - 要监听的父元素 * @param {string} selector - 目标选择器 (或者检查函数) * @param {number} timeout - 超时时间 (ms) * @returns {Promise} */ waitForElement(parent, selector, timeout = 2000) { return new Promise((resolve) => { // 1. 立即检查是否存在 let element = null; if (typeof selector === 'function') { element = selector(parent); } else { element = parent.querySelector(selector); } if (element) { return resolve(element); } // 2. 设置观察者 const observer = new MutationObserver((mutations) => { let found = null; if (typeof selector === 'function') { found = selector(parent); } else { found = parent.querySelector(selector); } if (found) { observer.disconnect(); resolve(found); } }); observer.observe(parent, { childList: true, subtree: true, attributes: true // 有时元素可能只是属性变化(如hidden移除) }); // 3. 设置超时 setTimeout(() => { observer.disconnect(); resolve(null); }, timeout); }); } /** * 带重试/等待的容器查找 * 优化:使用 MutationObserver 替代单纯的轮询 * @param {object} node - 节点对象 * @param {object} widget - 控件对象 * @param {object} options - 选项 * @returns {Promise} 容器信息 */ async findMountContainerWithRetry(node, widget, options = {}) { // 【优化】根据测试验证,Vue nodes 2.0 中 textarea 在节点容器添加时就已存在 // 因此大多数情况下不需要复杂的等待逻辑 const { timeout = 500 } = options; // 尝试立即查找(大多数情况下应该成功) const immediateResult = this.findMountContainer(node, widget); if (immediateResult && immediateResult.textarea) { return immediateResult; } // 如果是 Markdown/Note 节点且找到了容器但没找到 textarea if (immediateResult && immediateResult.isNoteNode) { // 继续往下,等待 textarea 出现 } const mode = this.detectRenderMode(); // Vue 模式下:简化的等待策略 if (mode === RENDER_MODE.VUE_NODES) { const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`); if (nodeContainer) { // 使用 Observer 短暂等待 textarea 出现 await this.waitForElement(nodeContainer, () => { const result = this.findMountContainer(node, widget); return (result && result.textarea) ? result : null; }, timeout); // 获取最终结果 const finalResult = this.findMountContainer(node, widget); if (finalResult && finalResult.textarea) { return finalResult; } } } else if (mode === RENDER_MODE.VUE_NODES) { // 【关键】nodeContainer 还不存在,需要等待节点容器渲染 // 监听画布容器,等待 nodeContainer 出现 const graphCanvas = document.querySelector('.graph-canvas-container') || document.querySelector('[class*="graph"]') || document.body; // 使用 Observer 等待 nodeContainer 出现 const waitResult = await this.waitForElement(graphCanvas, () => { const container = document.querySelector(`[data-node-id="${node.id}"]`); if (container) { // 找到节点容器后,再查找 textarea const result = this.findMountContainer(node, widget); return (result && result.textarea) ? result : null; } return null; }, timeout); if (waitResult) { return this.findMountContainer(node, widget); } } // 降级策略:快速重试一次(仅用于 LiteGraph 模式或 Observer 失效情况) await new Promise(r => setTimeout(r, 100)); const retryResult = this.findMountContainer(node, widget); if (retryResult && retryResult.textarea) return retryResult; logger.debugSample(() => `[NodeMountService] 容器查找未就绪 | 节点ID: ${node?.id}`); return null; } } // 创建单例实例 export const nodeMountService = new NodeMountService(); // 导出类(用于类型检查或继承) export { NodeMountService };