import { app } from "../../../scripts/app.js"; import { EventManager } from "../utils/eventManager.js"; import "../lib/Sortable.min.js"; /** * 调试开关:禁止自动折叠 * 在控制台输入 window.PA_DEBUG_NO_COLLAPSE = true 可禁止自动折叠 * 输入 window.PA_DEBUG_NO_COLLAPSE = false 可恢复自动折叠 */ window.PA_DEBUG_NO_COLLAPSE = window.PA_DEBUG_NO_COLLAPSE || false; // 锚点位置枚举 export const ANCHOR_POSITION = { TOP_LEFT_H: 'top-left-h', TOP_LEFT_V: 'top-left-v', TOP_CENTER_H: 'top-center-h', TOP_RIGHT_H: 'top-right-h', TOP_RIGHT_V: 'top-right-v', RIGHT_CENTER_V: 'right-center-v', BOTTOM_RIGHT_H: 'bottom-right-h', BOTTOM_RIGHT_V: 'bottom-right-v', BOTTOM_CENTER_H: 'bottom-center-h', BOTTOM_LEFT_H: 'bottom-left-h', BOTTOM_LEFT_V: 'bottom-left-v', LEFT_CENTER_V: 'left-center-v' }; export class AssistantContainer { constructor(options = {}) { this.nodeId = options.nodeId; this.type = options.type || 'prompt'; // 'prompt' or 'image' this.anchorPosition = options.anchorPosition || ANCHOR_POSITION.BOTTOM_RIGHT_H; this.offset = options.offset || { x: 0, y: 0 }; this.enableDragSort = options.enableDragSort !== false; // 回调函数 this.onButtonOrderChange = options.onButtonOrderChange; this.shouldCollapse = options.shouldCollapse; // 状态 this.isCollapsed = true; this.isTransitioning = false; this.isDestroyed = false; this.buttons = []; this.element = null; this.container = null; this.hoverArea = null; this.indicator = null; this.content = null; // 定时器 this._collapseTimer = null; this._expandTimer = null; // 事件清理函数 this._cleanupFunctions = []; // Sortable 实例 this._sortable = null; } render() { // 检查是否已销毁 if (this.isDestroyed) return null; // 主容器 this.element = document.createElement('div'); this.element.className = `assistant-container-common ${this.type}-assistant-container`; // 悬停区域(不可见,用于检测鼠标进入/离开) this.hoverArea = document.createElement('div'); this.hoverArea.className = 'assistant-hover-area'; this.element.appendChild(this.hoverArea); // 指示器(图标) this.indicator = document.createElement('div'); this.indicator.className = `assistant-indicator ${this.type}-assistant-indicator`; // 添加入场动画类 this.indicator.classList.add('indicator-init'); // 动画结束后移除初始化类 this.indicator.addEventListener('animationend', () => { this.indicator.classList.remove('indicator-init'); }, { once: true }); this.element.appendChild(this.indicator); // 按钮内容容器 this.content = document.createElement('div'); this.content.className = 'assistant-content'; this.element.appendChild(this.content); // 基于锚点的初始样式 this.updatePosition(); // 绑定事件 this._bindEvents(); // 设置 Sortable if (this.enableDragSort) { this._setupSortable(); } return this.element; } mount(parentElement) { if (parentElement) { parentElement.appendChild(this.element); // 挂载后强制回流/更新尺寸 requestAnimationFrame(() => this.updateDimensions()); } } setIconContent(svgContent) { if (this.indicator) { this.indicator.innerHTML = svgContent; } } addButton(buttonElement, id) { if (!buttonElement) return; buttonElement.dataset.id = id; // 用于 Sortable // 设置按钮索引,用于递进动画延迟 const buttonIndex = this.buttons.length; buttonElement.style.setProperty('--button-index', buttonIndex); this.content.appendChild(buttonElement); this.buttons.push({ id, element: buttonElement }); // 如果是分割线,根据当前布局方向设置类名 if (buttonElement.classList.contains('prompt-assistant-divider') || buttonElement.classList.contains('image-assistant-divider')) { const isVertical = this.anchorPosition.endsWith('-v'); if (isVertical) { buttonElement.classList.add('divider-horizontal'); } } this.updateDimensions(); } // 批量添加按钮,如果已存在Sortable,则会遵循Sortable的逻辑(通常append) // 如果需要特定顺序,应该在添加前排好序 addButtons(buttonElementsWithIds) { buttonElementsWithIds.forEach(({ element, id }) => { this.addButton(element, id); }); } // 清空按钮 clearButtons() { this.content.innerHTML = ''; this.buttons = []; } setAnchorPosition(position) { if (Object.values(ANCHOR_POSITION).includes(position)) { this.anchorPosition = position; this.updatePosition(); } } updatePosition() { if (!this.element) return; // 保存当前展开/折叠状态 const wasExpanded = !this.isCollapsed; // 重置类名,保持当前状态 const stateClass = wasExpanded ? 'expanded' : 'collapsed'; this.element.className = `assistant-container-common ${this.type}-assistant-container ${stateClass}`; // 添加布局类名 this.element.classList.add(`layout-${this.anchorPosition}`); // 确保内容容器的 Flex 方向正确 const isVertical = this.anchorPosition.endsWith('-v'); if (isVertical) { this.content.classList.add('flex-col'); this.content.classList.remove('flex-row'); } else { this.content.classList.add('flex-row'); this.content.classList.remove('flex-col'); } // 更新分割线类名:垂直布局时添加 divider-horizontal 类 this._updateDividerOrientation(isVertical); // 触发尺寸重新计算 this.updateDimensions(); } // 更新分割线方向类名 _updateDividerOrientation(isVertical) { if (!this.content) return; const dividers = this.content.querySelectorAll('.prompt-assistant-divider, .image-assistant-divider'); dividers.forEach(divider => { if (isVertical) { divider.classList.add('divider-horizontal'); } else { divider.classList.remove('divider-horizontal'); } }); } /** * 更新容器尺寸 (优化版:常量布局模式) * 根据当前启用的按钮组合直接计算尺寸,避免 DOM 克隆测量带来的开销 */ updateDimensions() { if (!this.element || !this.content) return; // --- 1. 获取当前状态统计 --- const buttons = Array.from(this.content.children).filter(el => el.style.display !== 'none' && !el.classList.contains('assistant-indicator') ); const totalCount = buttons.length; if (totalCount === 0) return; // 统计功能组 const hasHistoryGroup = buttons.some(el => el.dataset.id === 'history' || el.dataset.id === 'undo' || el.dataset.id === 'redo'); const hasDivider = buttons.some(el => el.classList.contains('prompt-assistant-divider') || el.classList.contains('image-assistant-divider')); // 计算非历史且非分隔线的有效功能按钮数量 const otherFeaturesCount = buttons.filter(el => !['history', 'undo', 'redo'].includes(el.dataset.id) && !el.classList.contains('prompt-assistant-divider') && !el.classList.contains('image-assistant-divider') ).length; // --- 2. 基于预设常量的尺寸映射 --- let finalDimension = 28; // 默认单个按钮宽度 (或折叠尺寸) // 逻辑规则匹配 (根据用户提供的精确测量值) if (hasHistoryGroup && otherFeaturesCount === 3) { finalDimension = 143; // 所有功能全开 (历史3 + 分隔线1 + 其它3) } else if (hasHistoryGroup && otherFeaturesCount === 2) { finalDimension = 121; // 历史 + 两个其它 } else if (hasHistoryGroup && otherFeaturesCount === 1) { finalDimension = 99; // 历史 + 一个其它 } else if (hasHistoryGroup && otherFeaturesCount === 0) { finalDimension = 77; // 只有历史功能 } else if (!hasHistoryGroup && otherFeaturesCount === 3) { finalDimension = 72; // 关闭历史的三个功能 } else if (!hasHistoryGroup && otherFeaturesCount === 2) { finalDimension = 50; // 只有两个按钮 } else if (!hasHistoryGroup && otherFeaturesCount === 1) { finalDimension = 28; // 只有一个按钮 } else { // 兜底动态计算逻辑: 基础28 + (额外按钮 * 22) + (如果有分隔线 ? 5 : 0) const extraCount = totalCount - 1; finalDimension = 28 + (extraCount * 22); if (hasDivider) finalDimension += 5; } // --- 3. 应用尺寸 --- const isVertical = this.anchorPosition.endsWith('-v'); if (isVertical) { // 竖向布局:宽度固定,高度动态 this.element.style.setProperty('--expanded-width', `28px`); this.element.style.setProperty('--expanded-height', `${finalDimension}px`); } else { // 横向布局:高度固定,宽度动态 this.element.style.setProperty('--expanded-width', `${finalDimension}px`); this.element.style.setProperty('--expanded-height', `28px`); } /* // --- 原有自动测量代码 (已注释,备用) --- const clone = this.content.cloneNode(true); clone.style.cssText = ` position: absolute; visibility: hidden; height: auto; width: auto; white-space: nowrap; display: flex; align-items: center; gap: 0; `; const isVerticalMeasure = this.anchorPosition.endsWith('-v'); clone.style.flexDirection = isVerticalMeasure ? 'column' : 'row'; document.body.appendChild(clone); const contentWidth = clone.scrollWidth; const contentHeight = clone.scrollHeight; document.body.removeChild(clone); const containerPadding = 4; const lastButtonMargin = 2; const collapsedSize = 28; let expandedWidth, expandedHeight; if (isVerticalMeasure) { expandedWidth = collapsedSize; expandedHeight = Math.max(contentHeight + containerPadding + lastButtonMargin, collapsedSize); } else { expandedWidth = Math.max(contentWidth + containerPadding + lastButtonMargin, collapsedSize); expandedHeight = collapsedSize; } this.element.style.setProperty('--expanded-width', `${expandedWidth}px`); this.element.style.setProperty('--expanded-height', `${expandedHeight}px`); */ } _bindEvents() { // 带中断逻辑的悬停处理 const onMouseEnter = () => this.expand(); const onMouseLeave = () => this.collapse(); // 绑定到悬停区域和元素本身 // 使用 EventManager 来绑定,以便于清理 this._cleanupFunctions.push(EventManager.addDOMListener(this.element, 'mouseenter', onMouseEnter)); this._cleanupFunctions.push(EventManager.addDOMListener(this.element, 'mouseleave', onMouseLeave)); } expand() { // 检查是否已销毁 if (this.isDestroyed) return; // 清除任何挂起的折叠定时器 if (this._collapseTimer) { clearTimeout(this._collapseTimer); this._collapseTimer = null; } // 先更新尺寸,确保 CSS 变量在展开前就设置好 this.updateDimensions(); // 根据锚点位置调整按钮递进动画索引 this._updateButtonStaggerIndex(); // 立即展开 this.isCollapsed = false; this.element.classList.remove('collapsed'); this.element.classList.add('expanded'); // 隐藏指示器 if (this.indicator) { this.indicator.style.opacity = '0'; this.indicator.style.pointerEvents = 'none'; } // 显示内容 if (this.content) { this.content.style.opacity = '1'; this.content.style.pointerEvents = 'auto'; } } // 根据锚点位置调整按钮递进动画索引 _updateButtonStaggerIndex() { if (!this.content) return; const children = Array.from(this.content.children); const totalButtons = children.length; // 判断是否需要反向索引 // 右侧布局向左展开、底部布局向上展开时,需要反向(最后的按钮先显示) const needReverse = this._isReverseStaggerDirection(); children.forEach((child, index) => { const staggerIndex = needReverse ? (totalButtons - 1 - index) : index; child.style.setProperty('--button-index', staggerIndex); }); } // 判断递进动画是否需要反向 _isReverseStaggerDirection() { // 从右侧/底部展开的布局需要反向 // right 布局:从右向左展开,最右边的按钮先显示 // bottom-v 布局:从下向上展开,最下面的按钮先显示 const pos = this.anchorPosition; // 横向布局:右侧的需要反向 if (pos.includes('right') && pos.endsWith('-h')) { return true; } // 竖向布局:底部的需要反向(column-reverse) if (pos.includes('bottom') && pos.endsWith('-v')) { return true; } return false; } collapse() { // 检查是否已销毁 if (this.isDestroyed) return; // 调试模式:禁止自动折叠 if (window.PA_DEBUG_NO_COLLAPSE) return; // 检查是否应阻止折叠(例如:激活的菜单) if (this.shouldCollapse && !this.shouldCollapse()) { return; } // 折叠前设置短暂延迟,允许鼠标在间隙/按钮之间移动 // 但如果用户移回,expand() 会取消此操作。 this._collapseTimer = setTimeout(() => { // 再次检查,因为在延迟期间状态可能已改变 if (this.shouldCollapse && !this.shouldCollapse()) { return; } this.isCollapsed = true; this.element.classList.remove('expanded'); this.element.classList.add('collapsed'); // 显示指示器(清除内联样式,让 CSS 变量 --assistant-icon-opacity 生效) if (this.indicator) { this.indicator.style.opacity = ''; this.indicator.style.pointerEvents = ''; } // 隐藏内容 if (this.content) { this.content.style.opacity = '0'; this.content.style.pointerEvents = 'none'; } // 折叠完成后,检测鼠标是否仍在热区内 // 解决自动折叠后鼠标仍在热区,但需要移出再移入才能展开的问题 this._checkMouseStillInHoverArea(); }, 150); // 为了易用性设置的小延迟 } // ---检测鼠标是否仍在热区内--- _checkMouseStillInHoverArea() { if (!this.element) return; // 使用 requestAnimationFrame 确保 DOM 已更新 requestAnimationFrame(() => { // 获取当前鼠标位置下的元素 const hoveredElements = document.querySelectorAll(':hover'); // 检查小助手容器或其子元素是否被悬停 let isMouseInside = false; for (const el of hoveredElements) { if (this.element.contains(el) || el === this.element) { isMouseInside = true; break; } } // 如果鼠标仍在热区内,且当前是折叠状态,则触发展开 if (isMouseInside && this.isCollapsed) { this.expand(); } }); } _setupSortable() { if (!this.content) return; this._sortable = new Sortable(this.content, { animation: 150, ghostClass: 'sortable-ghost', onEnd: (evt) => { const newOrder = Array.from(this.content.children) .map(el => el.dataset.id) .filter(Boolean); // 保存排序 if (this.onButtonOrderChange) { this.onButtonOrderChange(newOrder); } // 持久化到 settings this._saveOrderToSettings(newOrder); } }); } _saveOrderToSettings(order) { const settingKey = `PromptAssistant.ButtonOrder.${this.type}`; // 使用 app.ui.settings 保存 // ComfyUI 设置通常通过 app.ui.settings.setSettingValue(id, value) 设置 if (app.ui && app.ui.settings) { app.ui.settings.setSettingValue(settingKey, JSON.stringify(order)); } } restoreOrder() { const settingKey = `PromptAssistant.ButtonOrder.${this.type}`; if (!app.ui || !app.ui.settings) return; const orderStr = app.ui.settings.getSettingValue(settingKey); if (!orderStr) return; try { const order = JSON.parse(orderStr); if (!Array.isArray(order) || order.length === 0) return; // 按ID创建现有按钮的映射 const buttonMap = new Map(); Array.from(this.content.children).forEach(el => { if (el.dataset.id) { buttonMap.set(el.dataset.id, el); } }); // 按保存的顺序恢复按钮位置,新增按钮放在末尾 const existingButtons = Array.from(this.content.children); const orderedIds = new Set(order); // 首先追加排序后的项 order.forEach(id => { const el = buttonMap.get(id); if (el) { this.content.appendChild(el); } }); // 然后追加任何剩余项,如果它们不在顺序列表中 existingButtons.forEach(el => { if (el.dataset.id && !orderedIds.has(el.dataset.id)) { this.content.appendChild(el); } }); this.updateDimensions(); } catch (e) { logger.warn("[PromptAssistant] 恢复按钮顺序失败:", e); } } destroy() { // 防止重复销毁 if (this.isDestroyed) return; this.isDestroyed = true; // 清理定时器 if (this._collapseTimer) { clearTimeout(this._collapseTimer); this._collapseTimer = null; } if (this._expandTimer) { clearTimeout(this._expandTimer); this._expandTimer = null; } // 清理监听器 this._cleanupFunctions.forEach(fn => fn && fn()); this._cleanupFunctions = []; // 销毁 Sortable if (this._sortable) { this._sortable.destroy(); this._sortable = null; } // 移除元素 if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } // 清空所有引用 this.element = null; this.container = null; this.content = null; this.indicator = null; this.hoverArea = null; this.buttons = []; } }