816 lines
30 KiB
JavaScript
816 lines
30 KiB
JavaScript
/**
|
||
* 节点挂载服务 (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<HTMLElement|null>}
|
||
*/
|
||
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<object|null>} 容器信息
|
||
*/
|
||
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 };
|