/** * UI工具包 * 提供通用的UI操作工具函数,供各模块共用 */ import { logger } from './logger.js'; import { ResourceManager } from "./resourceManager.js"; import { APIService } from "../services/api.js"; class UIToolkit { // 中央按钮状态管理器 - 记录激活状态的按钮 static #activeButtonInfo = null; // {widget, buttonId, popupInstance} // 状态提示管理器 - 记录每个按钮的状态提示元素 static #statusTips = new Map(); // Map // 支持的输入字段ID static VALID_INPUT_IDS = ["text", "text_positive", "text_negative", "text_g", "text_l", "prompt", "string"]; // 状态文本管理 static STATUS_TEXTS = { translate: { loading: '翻译中', // success: (from, to) => `翻译完成 (${from} → ${to})`, success: (from, to) => '翻译完成', error: (msg) => msg || '翻译失败' }, expand: { loading: '提示词优化中', success: '提示词优化完成', error: (msg) => msg || '提示词优化失败' } }; /** * 检查输入控件是否为有效的文本输入 * 支持传统litegraph模式和Vue node2.0模式 */ static isValidInput(widget, options = {}) { const { debug = false, node = null } = options; let isValid = false; let reason = ''; const widgetName = (widget.name || widget.id || '').toLowerCase(); // 方法1: 标准文本输入控件(传统litegraph模式) if (widget.inputEl && widget.inputEl.tagName === "TEXTAREA" && (this.VALID_INPUT_IDS.includes(widget.name) || widgetName.includes('prompt'))) { isValid = true; reason = 'litegraph textarea matched'; } // 方法2: Note节点或带文本框的特殊输入 else if (widget.element && widget.element.tagName === "TEXTAREA") { isValid = true; reason = 'Note/Element textarea matched'; } // 方法3: Markdown Note节点特殊输入(Tiptap编辑器) else if (this.isMarkdownNoteInput(widget)) { isValid = true; reason = 'Markdown Note matched'; } // 方法4: Vue node2.0 模式检测 - 通过节点类型判断 else if (this._isVueNodesModeWidget(widget, node)) { isValid = true; reason = 'Vue node2.0 mode widget'; } // 方法5: 代理 Widget 解包 (例如子图上的 PromotedWidgetView) else if (typeof widget.resolveDeepest === 'function') { try { const deepest = widget.resolveDeepest(); if (deepest && deepest.widget) { const realWidget = deepest.widget; const realWidgetName = (realWidget.name || '').toLowerCase(); const typeLower = (realWidget.type || '').toLowerCase(); if (typeLower === "customtext" || (typeLower === "string" && realWidget.options?.multiline) || this.VALID_INPUT_IDS.includes(realWidget.name) || realWidgetName.includes('prompt')) { isValid = true; reason = 'Unwrapped PromotedWidgetView matched'; } } } catch (e) { const name = (widget.name || widget.sourceWidgetName || '').toLowerCase(); if (this.VALID_INPUT_IDS.includes(widget.name) || name.includes('prompt')) { isValid = true; reason = 'PromotedWidgetView name matched fallback'; } } } // ---可见性检测补救--- // 如果基本检测通过,但元素本身是隐藏的,则视为无效 // 注意:有些节点的 textarea 可能在初始化时 display:none,随后被 ComfyUI 脚本显示 if (isValid) { const element = widget.inputEl || widget.element; if (element && !this.isElementVisible(element)) { // 如果是标准 textarea,尝试检查父级可见性,或者放宽判断(有些节点在后台渲染) const isMarkdown = this.isMarkdownNoteInput(widget); if (!isMarkdown) { isValid = false; reason += ' (but element is hidden)'; } } } if (debug || (node && node.id === 74)) { const nodeType = node?.type || widget.node?.type || 'unknown'; logger.debug(`[isValidInput] 节点[${node?.id || '?'}] | 控件: ${widget.name || '?'}| 类型: ${nodeType} | 有效: ${isValid} | 原因: ${reason}`); } return isValid; } /** * 检查是否为 Vue node2.0 模式下的有效文本控件 * 在Vue模式下,widget对象可能没有inputEl/element,但节点类型可以判断 */ static _isVueNodesModeWidget(widget, node = null) { // 获取节点引用 const nodeRef = node || widget.node; if (!nodeRef) return false; // 检查是否为Vue节点模式 if (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode !== true) { return false; } // 已知支持的节点类型 const supportedNodeTypes = [ 'Note', 'MarkdownNote', 'PreviewAny', // Preview as Text节点(实际类型) 'PreviewTextNode', // Preview节点可能的其他名称 'Show any [Crystools]', 'CLIPTextEncode', // 显式包含 CLIP 相关 'CLIPTextEncodeSDXL', // 添加其他支持的节点类型 ]; // 检查节点类型 if (supportedNodeTypes.includes(nodeRef.type) || nodeRef.type?.includes('CLIP')) { return true; } // 检查控件名称是否为有效输入 const widgetName = (widget.name || widget.id || '').toLowerCase(); if (this.VALID_INPUT_IDS.includes(widget.name) || widgetName.includes('prompt')) { return true; } // Markdown类型节点检测(包括Preview as Text等使用comfy-markdown的节点) const typeLower = nodeRef.type?.toLowerCase() || ''; if (typeLower.includes('markdown') || (typeLower.includes('preview') && typeLower.includes('text'))) { return true; } return false; } /** * 检查是否为使用comfy-markdown的节点输入框 * 包括 MarkdownNote、Preview as Text 等使用 Tiptap/ProseMirror 编辑器的节点 */ static isMarkdownNoteInput(widget) { // 方法1: 检查节点类型 const nodeRef = widget.node; if (nodeRef) { // 支持的使用comfy-markdown的节点类型 const markdownNodeTypes = ['MarkdownNote', 'PreviewAny', 'PreviewTextNode']; if (markdownNodeTypes.includes(nodeRef.type)) { return true; } // 检查节点类型名称是否包含相关关键词 const typeLower = nodeRef.type?.toLowerCase() || ''; if (typeLower.includes('markdown') || (typeLower.includes('preview') && typeLower.includes('text'))) { return true; } } // 方法2: 检查widget名称(Vue模式下可能只有名称) if (widget.name === 'text' && nodeRef && nodeRef.type?.toLowerCase().includes('markdown')) { return true; } // 方法3: 通过 DOM 结构检测 let element = widget.element || widget.inputEl; if (!element) return false; // 向上查找 .comfy-markdown 容器 let parent = element.parentElement; while (parent) { if (parent.classList && parent.classList.contains('comfy-markdown')) { return true; } // Vue模式下检测 widget-markdown 类 if (parent.classList && parent.classList.contains('widget-markdown')) { return true; } parent = parent.parentElement; } // 检查元素本身是否是 Tiptap 编辑器 if (element.classList && (element.classList.contains('tiptap') || element.classList.contains('ProseMirror'))) { return true; } return false; } /** * 获取 Markdown Note 的 Tiptap 编辑器实例 * @param {HTMLElement} element - 输入框元素或相关元素 * @returns {Object|null} Tiptap 编辑器实例,如果找不到则返回 null */ static getMarkdownNoteEditor(element) { if (!element) return null; // 查找 .comfy-markdown 容器 let container = element; if (!container.classList || !container.classList.contains('comfy-markdown')) { container = element.closest('.comfy-markdown'); } if (!container) return null; // 查找 Tiptap 编辑器元素 const editorElement = container.querySelector('.tiptap.ProseMirror, .ProseMirror.tiptap'); if (!editorElement) return null; // 尝试从元素获取 Tiptap 编辑器实例 // Tiptap 通常会将编辑器实例存储在元素的某个属性中 if (editorElement.__tiptap_editor) { return editorElement.__tiptap_editor; } // 尝试从全局或父元素查找 if (container.__tiptap_editor) { return container.__tiptap_editor; } // 如果找不到实例,返回编辑器元素本身,后续可以通过 DOM 操作 return { element: editorElement, getHTML: () => editorElement.innerHTML, getText: () => editorElement.textContent || editorElement.innerText, setContent: (content) => { if (editorElement.__tiptap_editor) { editorElement.__tiptap_editor.commands.setContent(content); } else { // 降级方案:直接操作 DOM editorElement.innerHTML = content; // 触发更新事件 editorElement.dispatchEvent(new Event('input', { bubbles: true })); editorElement.dispatchEvent(new Event('change', { bubbles: true })); } } }; } /** * 显示状态提示 */ static showStatusTip(anchorElement, type, message, position = null) { // 移除同一按钮上的旧提示 this.removeStatusTip(anchorElement); // 创建提示元素 const tipElement = document.createElement('div'); tipElement.className = `statustip ${type}`; tipElement.textContent = message; document.body.appendChild(tipElement); // 设置位置和样式 const { posX, posY } = this._calculateTipPosition(anchorElement, position); tipElement.style.left = `${posX}px`; tipElement.style.top = `${posY}px`; // 根据position类型调整transform if (position === 'top') { tipElement.style.transform = 'translate(-50%, -100%) translateY(-12px)'; } else { tipElement.style.transform = 'translate(-50%, -100%) translateY(-8px)'; } tipElement.classList.add('statustip-show'); // 记录提示元素 this.#statusTips.set(anchorElement, tipElement); // 设置自动消失 this._setupTipAutoHide(tipElement, anchorElement); return tipElement; } /** * 移除指定按钮的状态提示 */ static removeStatusTip(anchorElement) { const existingTip = this.#statusTips.get(anchorElement); if (existingTip) { // 移除动画类 existingTip.classList.remove('statustip-show'); // 立即移除元素 existingTip.parentNode?.removeChild(existingTip); // 从管理器中移除记录 this.#statusTips.delete(anchorElement); } } /** * 计算提示气泡位置 */ static _calculateTipPosition(anchorElement, position) { let posX, posY; if (typeof position === 'object' && position !== null) { // 如果position是对象,直接使用其x,y值 posX = position.x; posY = position.y; } else if (position === 'top') { // 如果position是'top',将提示放在元素上方中央 const rect = anchorElement.getBoundingClientRect(); posX = rect.left + rect.width / 2; posY = rect.top; } else { // 默认情况,使用元素的位置 const rect = anchorElement.getBoundingClientRect(); posX = rect.left + rect.width / 2; posY = rect.top; } return { posX, posY }; } /** * 设置提示自动隐藏 */ static _setupTipAutoHide(tipElement, anchorElement) { // 增加显示时间到2秒 setTimeout(() => { // 检查提示是否仍然存在 if (this.#statusTips.get(anchorElement) === tipElement) { tipElement.classList.remove('statustip-show'); tipElement.classList.add('statustip-hide'); setTimeout(() => { // 再次检查提示是否仍然存在 if (this.#statusTips.get(anchorElement) === tipElement) { tipElement.parentNode?.removeChild(tipElement); this.#statusTips.delete(anchorElement); } }, 400); // 这是提示框淡出动画的时长 } }, 1000); // 显示1秒 } /** * 为按钮添加图标 * @param {HTMLElement} button 按钮元素 * @param {string} icon 图标名称,支持SVG或PrimeIcons,例如'icon-history.svg'或'pi-times' * @param {string} alt 替代文本 */ static addIconToButton(button, icon, alt) { if (!icon) return; try { button.innerHTML = ''; // 判断是否是PrimeIcon if (icon.startsWith('pi-')) { const iconSpan = document.createElement('span'); iconSpan.className = `pi ${icon}`; iconSpan.title = alt || ''; button.appendChild(iconSpan); return; } // 获取图标名称(确保带有.svg后缀) const iconName = icon.endsWith('.svg') ? icon : `${icon}.svg`; // 从ResourceManager获取图标 const cachedImg = ResourceManager.getIcon(iconName); if (cachedImg) { button.appendChild(cachedImg); cachedImg.alt = alt || ''; cachedImg.draggable = false; } else { logger.warn(`图标加载 | 结果:失败 | 图标: ${icon}, 原因: 未找到缓存`); } } catch (error) { logger.warn(`图标加载 | 结果:失败 | 图标: ${icon}, 错误: ${error.message}`); } } /** * 判断元素是否可见 */ static isElementVisible(element) { return element && element.style.display !== 'none' && element.style.visibility !== 'hidden'; } /** * 创建并添加DOM元素 */ static createElement(tagName, props = {}, parent = null) { const element = document.createElement(tagName); // 设置属性 Object.entries(props).forEach(([key, value]) => { if (key === 'className') { element.className = value; } else if (key === 'style') { Object.assign(element.style, value); } else if (key === 'content') { element.textContent = value; } else { element[key] = value; } }); // 添加到父元素 if (parent) { parent.appendChild(element); } return element; } // ====================== 按钮状态管理 ====================== /** * 获取当前激活的按钮信息 */ static getActiveButtonInfo() { return this.#activeButtonInfo; } /** * 设置当前激活的按钮信息 */ static setActiveButton(buttonInfo) { // 如果新旧按钮信息相同,不做任何处理 if (this.#activeButtonInfo && buttonInfo && this.#activeButtonInfo.widget === buttonInfo.widget && this.#activeButtonInfo.buttonId === buttonInfo.buttonId) { return; } // 清理旧的激活按钮状态 if (this.#activeButtonInfo) { const oldInfo = this.#activeButtonInfo; // 重置旧按钮状态 this.setButtonState(oldInfo.widget, oldInfo.buttonId, 'active', false); // logger.debug(`按钮状态重置 | 按钮:${oldInfo.buttonId} | 节点:${oldInfo.widget.nodeId}`); } // 设置新的激活按钮 this.#activeButtonInfo = buttonInfo; // 如果有新的按钮信息,设置其状态为激活 if (buttonInfo) { this.setButtonState(buttonInfo.widget, buttonInfo.buttonId, 'active', true); // logger.debug(`按钮激活 | 按钮:${buttonInfo.buttonId} | 节点:${buttonInfo.widget.nodeId}`); } } /** * 检查按钮是否为当前激活按钮 */ static isActiveButton(widget, buttonId) { return this.#activeButtonInfo && this.#activeButtonInfo.widget === widget && this.#activeButtonInfo.buttonId === buttonId; } /** * 处理弹窗相关按钮点击 */ static handlePopupButtonClick(e, widget, buttonId, showPopupFn, hidePopupFn) { e.preventDefault(); e.stopPropagation(); // logger.debug(`弹窗按钮点击 | 按钮: ${buttonId} | 节点: ${widget.nodeId}`); // 检查当前按钮状态 const isCurrentActive = this.isActiveButton(widget, buttonId); // 如果当前按钮已激活,则关闭弹窗 if (isCurrentActive) { // 重置按钮状态并隐藏弹窗 this.setActiveButton(null); hidePopupFn(); return; } // 设置新的激活按钮状态 const buttonInfo = { widget, buttonId, timestamp: Date.now() }; // **不要**在这里设置激活按钮,让弹窗管理器在清理完其他窗口后设置 // this.setActiveButton(buttonInfo); // 显示弹窗 showPopupFn({ anchorButton: e.currentTarget, nodeId: widget.nodeId, inputId: widget.inputId, widgetKey: widget.widgetKey, buttonInfo: buttonInfo, onClose: () => { // 弹窗关闭时,如果当前按钮仍为激活状态,则重置 if (this.isActiveButton(widget, buttonId)) { this.setActiveButton(null); } // 确保按钮状态恢复默认 this.setButtonState(widget, buttonId, 'active', false); // 触发额外的回调 if (typeof widget.onPopupClosed === 'function') { widget.onPopupClosed(buttonId); } } }); } /** * 获取按钮当前状态 */ static getButtonState(widget, buttonId) { if (!widget || !widget.buttons || !widget.buttons[buttonId]) { logger.error(`按钮状态获取 | 结果:失败 | 原因:按钮未找到 | 按钮ID:${buttonId}`); return null; } const button = widget.buttons[buttonId]; return { active: button.classList.contains('button-active'), processing: button.classList.contains('button-processing'), disabled: button.classList.contains('button-disabled') }; } /** * 设置按钮状态 */ static setButtonState(widget, buttonId, stateType, value = true) { try { const button = widget.buttons[buttonId]; if (!button) return; const stateClass = `button-${stateType}`; if (value) { button.classList.add(stateClass); // 如果是禁用状态,添加disabled属性 if (stateType === 'disabled') { button.setAttribute('disabled', 'disabled'); } } else { button.classList.remove(stateClass); // 如果取消禁用状态,移除disabled属性 if (stateType === 'disabled') { button.removeAttribute('disabled'); } } // 更新按钮可点击状态 this._updateButtonClickability(button); } catch (error) { logger.error(`按钮状态 | 设置失败 | 按钮:${buttonId} | 状态:${stateType} | 错误:${error.message}`); } } /** * 更新按钮可点击状态 */ static _updateButtonClickability(button) { // 检查按钮是否处于禁用状态 const isDisabled = button.classList.contains('button-disabled'); if (isDisabled) { // 如果按钮被禁用,阻止点击事件 button.style.pointerEvents = 'none'; } else { // 其他所有情况(包括processing)都允许点击, // 具体操作由事件监听器内部逻辑决定 button.style.pointerEvents = 'auto'; } } /** * 重置按钮状态 */ static resetButtonState(widget, buttonId = null) { try { const resetButton = (button, id) => { if (button) { // 移除所有状态类 button.classList.remove('button-active', 'button-processing', 'button-disabled'); // 移除disabled属性 button.removeAttribute('disabled'); // 恢复点击事件 button.style.pointerEvents = 'auto'; } }; if (buttonId) { // 重置指定按钮 const button = widget.buttons[buttonId]; resetButton(button, buttonId); } else { // 重置所有按钮 Object.entries(widget.buttons).forEach(([id, button]) => { resetButton(button, id); }); } } catch (error) { logger.error(`按钮状态 | 重置失败 | 按钮:${buttonId || 'all'} | 错误:${error.message}`); } } /** * 更新撤销/重做按钮状态 */ static updateUndoRedoButtonState(widget, LocalHistoryService) { // 更新撤销按钮状态 const undoButton = widget.buttons['undo']; if (undoButton) { const canUndo = LocalHistoryService.canUndo(widget.nodeId, widget.inputId); this.setButtonState(widget, 'undo', 'disabled', !canUndo); } // 更新重做按钮状态 const redoButton = widget.buttons['redo']; if (redoButton) { const canRedo = LocalHistoryService.canRedo(widget.nodeId, widget.inputId); this.setButtonState(widget, 'redo', 'disabled', !canRedo); } } /** * 处理按钮点击操作 */ static handleButtonOperation(e, widget, buttonId, operation, LocalHistoryService) { e.preventDefault(); e.stopPropagation(); // 获取按钮状态 const state = this.getButtonState(widget, buttonId); // 如果按钮已禁用或正在处理中,不执行操作 if (!state || state.disabled || state.processing) { logger.debug(`按钮操作 | 结果:跳过 | 按钮:${buttonId} | 原因:${state.disabled ? '已禁用' : '处理中'}`); return; } // 设置按钮为处理中状态 this.setButtonState(widget, buttonId, 'processing', true); // logger.debug(`按钮操作 | 动作:开始 | 按钮:${buttonId}`); // 执行操作(接收一个回调函数用于处理操作完成后的逻辑) try { const callback = (success = true, error = null) => { // 重置按钮状态 this.setButtonState(widget, buttonId, 'processing', false); // 如果操作成功且有结果,将结果写入历史记录 if (success && widget.inputEl && widget.inputEl.value) { // 添加到历史 // logger.debug(`准备写入历史| ${buttonId}操作完成| node_id=${widget.nodeId}`); LocalHistoryService.addHistory({ workflow_id: '', node_id: widget.nodeId, input_id: widget.inputId, content: widget.inputEl.value, operation_type: buttonId, // 使用按钮ID作为操作类型 }); } if (error) { logger.error(`按钮操作完成 | 结果:失败 | 按钮:${buttonId} | 错误:${error}`); this.showStatusTip( e.currentTarget, 'error', `操作失败: ${error?.message || '未知错误'}`, null ); } else { // logger.debug(`按钮操作完成 | 结果:${success ? '成功' : '失败'} | 按钮:${buttonId}`); this.showStatusTip( e.currentTarget, 'success', `${e.currentTarget.title || buttonId} 操作完成`, null ); } }; // 执行操作并传入回调 // logger.debug(`历史缓存 | 按钮操作准备执行 node_id=${widget.nodeId} input_id=${widget.inputId} type=${buttonId}`); operation(callback); } catch (error) { // 出现异常时重置按钮状态 this.setButtonState(widget, buttonId, 'processing', false); logger.error(`按钮操作 | 结果:异常 | 按钮:${buttonId} | 错误:${error.message}`); this.showStatusTip( e.currentTarget, 'error', `操作异常: ${error.message}`, null ); } } /** * 处理按钮点击 * 显示状态提示信息 */ static handleButtonClick(e, widget, message, type) { const btnRect = e.target.getBoundingClientRect(); this.showStatusTip( e.target, type, message, { x: btnRect.left + btnRect.width / 2, y: btnRect.top } ); } /** * 写入内容到输入框 */ static writeToInput(content, nodeId, inputId, options = { highlight: true, focus: false, widgetKey: null }) { const mapping = this._findMapping(nodeId, inputId, options.widgetKey); if (mapping && mapping.inputEl) { const inputEl = mapping.inputEl; // 检查是否为 Markdown Note const editor = this.getMarkdownNoteEditor(inputEl); if (editor) { // 使用 Tiptap 编辑器写入内容 editor.setContent(content); // 添加高亮效果(对编辑器元素) if (options.highlight && editor.element) { this._highlightInput(editor.element); } // 聚焦编辑器 if (options.focus && editor.element) { editor.element.focus(); } logger.debug(`内容写入 | 结果:成功 | 节点:${nodeId} | 输入框:${inputId} | 类型:Markdown Note`); return true; } // 标准输入框处理 // 将内容写入输入框 inputEl.value = content; // 触发input事件,确保UI和数据同步 inputEl.dispatchEvent(new Event('input', { bubbles: true })); inputEl.dispatchEvent(new Event('change', { bubbles: true })); // 添加高亮效果 if (options.highlight) { this._highlightInput(inputEl); } // 聚焦输入框 if (options.focus) { inputEl.focus(); } logger.debug(`内容写入 | 结果:成功 | 节点:${nodeId} | 输入框:${inputId}`); return true; } else { logger.error(`内容写入 | 结果:失败 | 节点:${nodeId} | 输入框:${inputId} | 原因:找不到输入框`); return false; } } /** * 在光标位置插入内容 */ static insertAtCursor(content, nodeId, inputId, options = { highlight: true, keepFocus: true, widgetKey: null }) { const mapping = this._findMapping(nodeId, inputId, options.widgetKey); if (mapping && mapping.inputEl) { const inputEl = mapping.inputEl; // 检查是否为 Markdown Note const editor = this.getMarkdownNoteEditor(inputEl); if (editor) { // 对于 Markdown Note,使用 Tiptap 的插入命令 if (editor.element && editor.element.__tiptap_editor) { const tiptapEditor = editor.element.__tiptap_editor; // 使用 Tiptap 的 insertContent 命令 tiptapEditor.commands.insertContent(content); } else { // 降级方案:获取当前文本,插入内容,然后设置回去 const currentText = editor.getText(); const selection = window.getSelection(); let cursorPos = 0; if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(editor.element); preCaretRange.setEnd(range.endContainer, range.endOffset); cursorPos = preCaretRange.toString().length; } const beforeText = currentText.substring(0, cursorPos); const afterText = currentText.substring(cursorPos); const newContent = beforeText + content + afterText; editor.setContent(newContent); } // 添加高亮效果 if (options.highlight && editor.element) { this._highlightInput(editor.element); } // 保持焦点 if (options.keepFocus && editor.element) { editor.element.focus(); } logger.debug(`内容插入 | 结果:成功 | 节点:${nodeId} | 输入框:${inputId} | 类型:Markdown Note`); return true; } // 标准输入框处理 const currentValue = inputEl.value; const cursorPos = inputEl.selectionStart; const beforeText = currentValue.substring(0, cursorPos); const afterText = currentValue.substring(inputEl.selectionEnd); // 插入内容 const newValue = beforeText + content + afterText; inputEl.value = newValue; // 触发事件 inputEl.dispatchEvent(new Event('input', { bubbles: true })); inputEl.dispatchEvent(new Event('change', { bubbles: true })); // 更新光标位置 const newPos = cursorPos + content.length; inputEl.setSelectionRange(newPos, newPos); // 添加高亮效果 if (options.highlight) { this._highlightInput(inputEl); } // 根据参数决定是否保持焦点 if (options.keepFocus) { inputEl.focus(); } logger.debug(`内容插入 | 结果:成功 | 节点:${nodeId} | 输入框:${inputId}`); return true; } else { logger.error(`内容插入 | 结果:失败 | 节点:${nodeId} | 输入框:${inputId} | 原因:找不到输入框`); return false; } } /** * 根据 nodeID 和 inputID 查找映射关系的辅助方法 * 优先使用 widgetKey,如果未提供则尝试匹配唯一映射 */ static _findMapping(nodeId, inputId, widgetKey = null) { if (widgetKey) { const mapping = window.PromptAssistantInputWidgetMap?.[widgetKey]; if (mapping) return mapping; } // 尝试使用旧格式 key const oldKey = `${nodeId}_${inputId}`; const oldMapping = window.PromptAssistantInputWidgetMap?.[oldKey]; if (oldMapping) return oldMapping; // 如果仍未找到,尝试在所有映射中查找匹配 nodeId 和 inputId 的项 // 这在 graphId 无法确定的情况下非常有用 const mappings = Object.values(window.PromptAssistantInputWidgetMap || {}); const matches = mappings.filter(m => (m.widget?.nodeId == nodeId && m.widget?.inputId == inputId) || (m.inputEl && m.inputEl._nodeId == nodeId && m.inputEl._inputId == inputId) ); if (matches.length === 1) { return matches[0]; } else if (matches.length > 1) { logger.warn(`查找映射 | 结果:冲突 | 找到多个匹配项 | 节点:${nodeId} | 输入框:${inputId}`); // 如果有多个匹配,尝试匹配 nodeId 且没有 graphId 的那个(旧版本) const bestMatch = matches.find(m => !m.widgetKey || !m.widgetKey.includes('_')); return bestMatch || matches[0]; } return null; } /** * 为输入框添加高亮动画效果 */ static _highlightInput(inputEl) { if (!inputEl) return; // 移除旧的高亮状态 this.removeHighlight(inputEl); // 强制重绘,确保动画可以重新开始 void inputEl.offsetWidth; // 添加动画类 inputEl.classList.add('input-highlight'); // 动画结束后移除类 // 使用 200ms 匹配 CSS 中的动画时长 (common.css: L181) inputEl._highlightTimer = setTimeout(() => { this.removeHighlight(inputEl); }, 200); } /** * 显式移除元素的高亮状态并清理定时器 * 用于在布局重排(如滚动条出现)前清理渲染负担 * @param {HTMLElement} inputEl 目标元素 */ static removeHighlight(inputEl) { if (!inputEl) return; // 移除动画类 inputEl.classList.remove('input-highlight'); // 清理定时器 if (inputEl._highlightTimer) { clearTimeout(inputEl._highlightTimer); inputEl._highlightTimer = null; } } /** * 处理异步按钮操作 */ static async handleAsyncButtonOperation(widget, buttonId, buttonElement, asyncOperation) { const statusConfig = this.STATUS_TEXTS[buttonId] || { loading: '处理中', success: '完成', error: (msg) => msg || '操作失败' }; let currentRequestId = null; let cancellationTimer = null; let cancelClickHandler = null; try { // 设置当前按钮为处理中状态 this.setButtonState(widget, buttonId, 'processing', true); // 禁用其他按钮 Object.keys(widget.buttons).forEach(id => { if (id !== buttonId) { this.setButtonState(widget, id, 'disabled', true); } }); // 执行异步操作,并捕获请求ID const result = await asyncOperation((requestId) => { currentRequestId = requestId; // 0.5秒后允许取消 cancellationTimer = setTimeout(() => { // 检查按钮是否仍在处理中 const currentState = this.getButtonState(widget, buttonId); if (currentState && currentState.processing) { buttonElement.classList.add('cancellable'); // 临时添加取消事件 cancelClickHandler = async (e) => { e.preventDefault(); e.stopPropagation(); if (currentRequestId) { logger.debug(`用户取消请求 | ID: ${currentRequestId}`); await APIService.cancelRequest(currentRequestId); // 根据按钮ID显示不同的取消提示 let cancelMessage = '请求已取消'; if (buttonId === 'expand') { cancelMessage = '提示词优化已取消'; } else if (buttonId === 'translate') { cancelMessage = '翻译已取消'; } // 显示取消提示 this.showStatusTip(buttonElement, 'info', cancelMessage); } }; buttonElement.addEventListener('click', cancelClickHandler); } }, 500); }); // 根据操作结果显示不同的提示 if (result && result.success) { const btnRect = buttonElement.getBoundingClientRect(); const tipPosition = { x: btnRect.left + btnRect.width / 2, y: btnRect.top }; if (result.useCache && result.tipType && result.tipMessage) { this.showStatusTip( result.buttonElement || buttonElement, result.tipType, result.tipMessage, tipPosition ); } else if (!result.useCache) { this.showStatusTip( buttonElement, 'success', typeof statusConfig.success === 'function' ? statusConfig.success(result.from, result.to) : statusConfig.success, tipPosition ); } } else { // 如果是用户取消的,则不显示错误 if (!result?.cancelled) { throw new Error(statusConfig.error(result?.error)); } } } catch (error) { // 如果是用户取消的,错误信息已在API层处理,这里不再显示 if (error.name !== 'AbortError' && !error.message.includes('aborted')) { const btnRect = buttonElement.getBoundingClientRect(); this.showStatusTip( buttonElement, 'error', error.message, { x: btnRect.left + btnRect.width / 2, y: btnRect.top } ); logger.error(`按钮操作失败 | 按钮:${buttonId} | 错误:${error.message}`); } } finally { // 清理 if (cancellationTimer) clearTimeout(cancellationTimer); if (cancelClickHandler) buttonElement.removeEventListener('click', cancelClickHandler); buttonElement.classList.remove('cancellable'); // 恢复为旧的、可靠的重置逻辑 // 重置当前按钮状态 this.setButtonState(widget, buttonId, 'processing', false); // 恢复其他按钮状态 Object.keys(widget.buttons).forEach(id => { if (id !== buttonId) { this.setButtonState(widget, id, 'disabled', false); } }); // 操作完成后,触发小助手自动折叠 setTimeout(() => { if (window.promptAssistant && typeof window.promptAssistant.triggerAutoCollapse === 'function') { window.promptAssistant.triggerAutoCollapse(widget); logger.debug(`异步操作完成 | 触发自动折叠 | 按钮:${buttonId}`); } }, 1500); } } } export { UIToolkit };