Files
yawiii-ComfyUI-Prompt-Assis…/js/modules/AssistantContainer.js
T
2025-12-21 22:52:38 +08:00

452 lines
15 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";
// 锚点位置枚举
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.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() {
// 主容器
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;
// 重置类名
this.element.className = `assistant-container-common ${this.type}-assistant-container collapsed`;
// 添加布局类名
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');
}
});
}
updateDimensions() {
if (!this.element || !this.content) return;
// 测量内容尺寸
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;
`;
// 根据方向设置 flex-direction
const isVertical = this.anchorPosition.endsWith('-v');
clone.style.flexDirection = isVertical ? 'column' : 'row';
document.body.appendChild(clone);
const contentWidth = clone.scrollWidth;
const contentHeight = clone.scrollHeight;
document.body.removeChild(clone);
// 容器的 padding
const containerPadding = 4; // 2px padding * 2
// 按钮的 margin (每个按钮都有 margin: 2px)
// 注意:scrollWidth/scrollHeight 不包含最后一个子元素的 margin-right/margin-bottom
// 所以需要手动添加最后一个按钮的 margin
const lastButtonMargin = 2; // 最后一个按钮的 margin-right 或 margin-bottom
// 折叠状态的固定尺寸
const collapsedSize = 28;
// 根据布局方向计算展开后的尺寸
let expandedWidth, expandedHeight;
if (isVertical) {
// 竖向布局:宽度固定为折叠尺寸,高度根据内容计算
expandedWidth = collapsedSize;
// contentHeight 不包含最后一个按钮的 margin-bottom,需要手动添加
expandedHeight = Math.max(contentHeight + containerPadding + lastButtonMargin, collapsedSize);
} else {
// 横向布局:高度固定为折叠尺寸,宽度根据内容计算
// contentWidth 不包含最后一个按钮的 margin-right,需要手动添加
expandedWidth = Math.max(contentWidth + containerPadding + lastButtonMargin, collapsedSize);
expandedHeight = collapsedSize;
}
// 更新 CSS 变量
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._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.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';
}
}, 150); // 为了易用性设置的小延迟
}
_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) {
console.warn("[PromptAssistant] 恢复按钮顺序失败:", e);
}
}
destroy() {
// 清理监听器
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);
}
}
}