Files
yawiii-ComfyUI-Prompt-Assis…/js/modules/AssistantContainer.js
T
2026-01-10 17:38:47 +08:00

575 lines
20 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.
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 = [];
}
}