Files
LittleWhiteBox/modules/novel-draw/floating-panel.js
2026-01-18 02:20:37 +08:00

1014 lines
37 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.
// floating-panel.js
/**
* NovelDraw 画图按钮面板
* 和 TTS 播放器一样,每条 AI 消息都有一个
*/
import {
openNovelDrawSettings,
generateAndInsertImages,
getSettings,
saveSettings,
classifyError,
} from './novel-draw.js';
import { registerToToolbar, removeFromToolbar } from '../../core/message-toolbar.js';
// ═══════════════════════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════════════════════
const AUTO_RESET_DELAY = 8000;
const FloatState = {
IDLE: 'idle',
LLM: 'llm',
GEN: 'gen',
COOLDOWN: 'cooldown',
SUCCESS: 'success',
PARTIAL: 'partial',
ERROR: 'error',
};
const SIZE_OPTIONS = [
{ value: 'default', label: '跟随预设', width: null, height: null },
{ value: '832x1216', label: '832 × 1216 竖图', width: 832, height: 1216 },
{ value: '1216x832', label: '1216 × 832 横图', width: 1216, height: 832 },
{ value: '1024x1024', label: '1024 × 1024 方图', width: 1024, height: 1024 },
{ value: '768x1280', label: '768 x 1280 大竖', width: 768, height: 1280 },
{ value: '1280x768', label: '1280 x 768 大横', width: 1280, height: 768 },
];
// ═══════════════════════════════════════════════════════════════════════════
// 状态(每条消息独立)
// ═══════════════════════════════════════════════════════════════════════════
const panelMap = new Map(); // messageId -> panelData
const pendingCallbacks = new Map(); // messageId -> true
let observer = null;
let stylesInjected = false;
// ═══════════════════════════════════════════════════════════════════════════
// 样式 - 菜单向下展开
// ═══════════════════════════════════════════════════════════════════════════
const STYLES = `
:root {
--nd-h: 34px;
--nd-bg: rgba(0, 0, 0, 0.55);
--nd-bg-hover: rgba(0, 0, 0, 0.7);
--nd-bg-active: rgba(255, 255, 255, 0.1);
--nd-border: rgba(255, 255, 255, 0.08);
--nd-border-hover: rgba(255, 255, 255, 0.2);
--nd-border-subtle: rgba(255, 255, 255, 0.08);
--nd-text-primary: rgba(255, 255, 255, 0.85);
--nd-text-secondary: rgba(255, 255, 255, 0.65);
--nd-text-muted: rgba(255, 255, 255, 0.45);
--nd-text-dim: rgba(255, 255, 255, 0.25);
--nd-success: #3ecf8e;
--nd-warning: #f0b429;
--nd-error: #f87171;
--nd-info: #60a5fa;
--nd-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
--nd-radius-sm: 6px;
--nd-radius-md: 10px;
--nd-radius-lg: 14px;
}
.nd-float {
position: relative;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.nd-capsule {
width: 74px;
height: var(--nd-h);
background: var(--nd-bg);
border: 1px solid var(--nd-border);
border-radius: 17px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
position: relative;
overflow: hidden;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.nd-float:hover .nd-capsule {
background: var(--nd-bg-hover);
border-color: var(--nd-border-hover);
}
.nd-float.working .nd-capsule { border-color: rgba(240, 180, 41, 0.5); }
.nd-float.cooldown .nd-capsule { border-color: rgba(96, 165, 250, 0.6); background: rgba(96, 165, 250, 0.1); }
.nd-float.success .nd-capsule { border-color: rgba(62, 207, 142, 0.6); background: rgba(62, 207, 142, 0.1); }
.nd-float.partial .nd-capsule { border-color: rgba(240, 180, 41, 0.6); background: rgba(240, 180, 41, 0.1); }
.nd-float.error .nd-capsule { border-color: rgba(248, 113, 113, 0.6); background: rgba(248, 113, 113, 0.1); }
.nd-inner {
display: grid;
width: 100%;
height: 100%;
grid-template-areas: "s";
pointer-events: none;
}
.nd-layer {
grid-area: s;
display: flex;
align-items: center;
width: 100%;
height: 100%;
transition: opacity 0.2s, transform 0.2s;
pointer-events: auto;
}
.nd-layer-idle { opacity: 1; transform: translateY(0); }
.nd-float.working .nd-layer-idle,
.nd-float.cooldown .nd-layer-idle,
.nd-float.success .nd-layer-idle,
.nd-float.partial .nd-layer-idle,
.nd-float.error .nd-layer-idle {
opacity: 0;
transform: translateY(-100%);
pointer-events: none;
}
.nd-btn-draw {
flex: 1;
height: 100%;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
color: var(--nd-text-primary);
transition: background 0.15s;
font-size: 16px;
}
.nd-btn-draw:hover { background: rgba(255, 255, 255, 0.12); }
.nd-btn-draw:active { transform: scale(0.92); }
.nd-auto-dot {
position: absolute;
top: 7px;
right: 6px;
width: 6px;
height: 6px;
background: var(--nd-success);
border-radius: 50%;
box-shadow: 0 0 6px rgba(62, 207, 142, 0.6);
opacity: 0;
transform: scale(0);
transition: all 0.2s;
}
.nd-float.auto-on .nd-auto-dot { opacity: 1; transform: scale(1); }
.nd-sep { width: 1px; height: 12px; background: var(--nd-border); }
.nd-btn-menu {
width: 24px;
height: 100%;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--nd-text-dim);
font-size: 8px;
opacity: 0.6;
transition: opacity 0.25s, transform 0.25s;
}
.nd-float:hover .nd-btn-menu { opacity: 1; }
.nd-btn-menu:hover { background: rgba(255, 255, 255, 0.12); color: var(--nd-text-muted); }
.nd-arrow { transition: transform 0.2s; }
.nd-float.expanded .nd-arrow { transform: rotate(180deg); }
.nd-layer-active {
opacity: 0;
transform: translateY(100%);
justify-content: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #fff;
cursor: pointer;
pointer-events: none;
}
.nd-float.working .nd-layer-active,
.nd-float.cooldown .nd-layer-active,
.nd-float.success .nd-layer-active,
.nd-float.partial .nd-layer-active,
.nd-float.error .nd-layer-active {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.nd-float.cooldown .nd-layer-active { color: var(--nd-info); }
.nd-float.success .nd-layer-active { color: var(--nd-success); }
.nd-float.partial .nd-layer-active { color: var(--nd-warning); }
.nd-float.error .nd-layer-active { color: var(--nd-error); }
.nd-spin { display: inline-block; animation: nd-spin 1.5s linear infinite; }
@keyframes nd-spin { to { transform: rotate(360deg); } }
.nd-countdown { font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; }
/* ═══════════════════════════════════════════════════════════════════════════
详情弹窗 - 向下展开
═══════════════════════════════════════════════════════════════════════════ */
.nd-detail {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: rgba(18, 18, 22, 0.96);
border: 1px solid var(--nd-border);
border-radius: 12px;
padding: 12px 16px;
font-size: 12px;
color: var(--nd-text-secondary);
white-space: nowrap;
box-shadow: var(--nd-shadow-lg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
opacity: 0;
visibility: hidden;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 100;
transform: translateY(-6px) scale(0.96);
transform-origin: top right;
}
.nd-float.show-detail .nd-detail {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.nd-detail-row { display: flex; align-items: center; gap: 10px; padding: 3px 0; }
.nd-detail-row + .nd-detail-row { margin-top: 6px; padding-top: 8px; border-top: 1px solid var(--nd-border-subtle); }
.nd-detail-icon { opacity: 0.6; font-size: 13px; }
.nd-detail-label { color: var(--nd-text-muted); }
.nd-detail-value { margin-left: auto; font-weight: 600; color: var(--nd-text-primary); }
.nd-detail-value.success { color: var(--nd-success); }
.nd-detail-value.warning { color: var(--nd-warning); }
.nd-detail-value.error { color: var(--nd-error); }
/* ═══════════════════════════════════════════════════════════════════════════
菜单 - 向下展开
═══════════════════════════════════════════════════════════════════════════ */
.nd-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 190px;
background: rgba(18, 18, 22, 0.96);
border: 1px solid var(--nd-border);
border-radius: 12px;
padding: 10px;
box-shadow: var(--nd-shadow-lg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
opacity: 0;
visibility: hidden;
transform: translateY(-6px) scale(0.96);
transform-origin: top right;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 100;
}
.nd-float.expanded .nd-menu {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.nd-card {
background: transparent;
border: none;
border-radius: 0;
overflow: visible;
}
.nd-row { display: flex; align-items: center; gap: 10px; padding: 6px 2px; }
.nd-label {
font-size: 11px;
color: var(--nd-text-muted);
width: 32px;
flex-shrink: 0;
padding: 0;
}
.nd-select {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--nd-border-subtle);
color: var(--nd-text-primary);
font-size: 11px;
border-radius: 6px;
padding: 6px 8px;
margin: 0;
box-sizing: border-box;
outline: none;
cursor: pointer;
text-align: center;
text-align-last: center;
transition: border-color 0.2s;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
.nd-select:hover { border-color: rgba(255, 255, 255, 0.2); }
.nd-select:focus { border-color: rgba(255, 255, 255, 0.3); }
.nd-select option { background: #1a1a1e; color: #eee; text-align: left; }
.nd-select.size { font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 10px; }
.nd-inner-sep { display: none; }
.nd-controls { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.nd-auto {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--nd-border-subtle);
border-radius: var(--nd-radius-sm);
cursor: pointer;
transition: all 0.15s;
}
.nd-auto:hover { background: rgba(255, 255, 255, 0.08); }
.nd-auto.on { background: rgba(62, 207, 142, 0.08); border-color: rgba(62, 207, 142, 0.3); }
.nd-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.2s;
}
.nd-auto.on .nd-dot { background: var(--nd-success); box-shadow: 0 0 8px rgba(62, 207, 142, 0.5); }
.nd-auto-text { font-size: 12px; color: var(--nd-text-muted); }
.nd-auto:hover .nd-auto-text { color: var(--nd-text-secondary); }
.nd-auto.on .nd-auto-text { color: rgba(62, 207, 142, 0.95); }
.nd-gear {
width: 36px;
height: 36px;
border: 1px solid var(--nd-border-subtle);
border-radius: var(--nd-radius-sm);
background: rgba(255, 255, 255, 0.03);
color: var(--nd-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.15s;
}
.nd-gear:hover { background: rgba(255, 255, 255, 0.08); color: var(--nd-text-secondary); }
`;
function injectStyles() {
if (stylesInjected) return;
stylesInjected = true;
const el = document.createElement('style');
el.id = 'nd-float-styles';
el.textContent = STYLES;
document.head.appendChild(el);
}
// ═══════════════════════════════════════════════════════════════════════════
// 面板数据结构
// ═══════════════════════════════════════════════════════════════════════════
function createPanelData(messageId) {
return {
messageId,
root: null,
state: FloatState.IDLE,
result: { success: 0, total: 0, error: null, startTime: 0 },
autoResetTimer: null,
cooldownRafId: null,
cooldownEndTime: 0,
$cache: {},
_cleanup: null,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 面板创建 - 箭头改为向下 ▼
// ═══════════════════════════════════════════════════════════════════════════
function buildPresetOptions() {
const settings = getSettings();
const presets = settings.paramsPresets || [];
const currentId = settings.selectedParamsPresetId;
return presets.map(p =>
`<option value="${p.id}"${p.id === currentId ? ' selected' : ''}>${p.name || '未命名'}</option>`
).join('');
}
function buildSizeOptions() {
const settings = getSettings();
const current = settings.overrideSize || 'default';
return SIZE_OPTIONS.map(opt =>
`<option value="${opt.value}"${opt.value === current ? ' selected' : ''}>${opt.label}</option>`
).join('');
}
function fillSelectOptions(select, options, currentValue) {
if (!select) return;
select.textContent = '';
const currentStr = currentValue == null ? null : String(currentValue);
let selectedSet = false;
options.forEach((opt) => {
const option = document.createElement('option');
const valueStr = String(opt.value);
option.value = valueStr;
option.textContent = opt.label;
if (currentStr !== null && valueStr === currentStr) {
option.selected = true;
selectedSet = true;
}
select.appendChild(option);
});
if (!selectedSet && options.length > 0) {
select.selectedIndex = 0;
}
}
function createPanelElement(messageId) {
const settings = getSettings();
const isAuto = settings.mode === 'auto';
const el = document.createElement('div');
el.className = `nd-float${isAuto ? ' auto-on' : ''}`;
el.dataset.messageId = messageId;
// Template-only UI markup built locally.
// eslint-disable-next-line no-unsanitized/property
el.innerHTML = `
<div class="nd-capsule">
<div class="nd-inner">
<div class="nd-layer nd-layer-idle">
<button class="nd-btn-draw" title="点击生成配图">
<span>🎨</span>
<span class="nd-auto-dot"></span>
</button>
<div class="nd-sep"></div>
<button class="nd-btn-menu" title="展开菜单">
<span class="nd-arrow">▼</span>
</button>
</div>
<div class="nd-layer nd-layer-active">
<span class="nd-status-icon">⏳</span>
<span class="nd-status-text">分析</span>
</div>
</div>
</div>
<div class="nd-detail">
<div class="nd-detail-row">
<span class="nd-detail-icon">📊</span>
<span class="nd-detail-label">结果</span>
<span class="nd-detail-value nd-result">-</span>
</div>
<div class="nd-detail-row nd-error-row" style="display:none">
<span class="nd-detail-icon">💡</span>
<span class="nd-detail-label">原因</span>
<span class="nd-detail-value error nd-error">-</span>
</div>
<div class="nd-detail-row">
<span class="nd-detail-icon">⏱</span>
<span class="nd-detail-label">耗时</span>
<span class="nd-detail-value nd-time">-</span>
</div>
</div>
<div class="nd-menu">
<div class="nd-card">
<div class="nd-row">
<span class="nd-label">预设</span>
<select class="nd-select nd-preset-select">${buildPresetOptions()}</select>
</div>
<div class="nd-inner-sep"></div>
<div class="nd-row">
<span class="nd-label">尺寸</span>
<select class="nd-select size nd-size-select">${buildSizeOptions()}</select>
</div>
</div>
<div class="nd-controls">
<div class="nd-auto${isAuto ? ' on' : ''} nd-auto-toggle">
<span class="nd-dot"></span>
<span class="nd-auto-text">自动配图</span>
</div>
<button class="nd-gear nd-settings-btn" title="打开设置">⚙</button>
</div>
</div>
`;
return el;
}
function cacheDOM(panelData) {
const el = panelData.root;
if (!el) return;
panelData.$cache = {
statusIcon: el.querySelector('.nd-status-icon'),
statusText: el.querySelector('.nd-status-text'),
result: el.querySelector('.nd-result'),
errorRow: el.querySelector('.nd-error-row'),
error: el.querySelector('.nd-error'),
time: el.querySelector('.nd-time'),
presetSelect: el.querySelector('.nd-preset-select'),
sizeSelect: el.querySelector('.nd-size-select'),
autoToggle: el.querySelector('.nd-auto-toggle'),
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 状态管理(每个面板独立)
// ═══════════════════════════════════════════════════════════════════════════
function setState(messageId, state, data = {}) {
const panelData = panelMap.get(messageId);
if (!panelData?.root) return;
const el = panelData.root;
panelData.state = state;
// 清除旧定时器
if (panelData.autoResetTimer) {
clearTimeout(panelData.autoResetTimer);
panelData.autoResetTimer = null;
}
if (state !== FloatState.COOLDOWN && panelData.cooldownRafId) {
cancelAnimationFrame(panelData.cooldownRafId);
panelData.cooldownRafId = null;
panelData.cooldownEndTime = 0;
}
// 移除状态类
el.classList.remove('working', 'cooldown', 'success', 'partial', 'error', 'show-detail');
const { statusIcon, statusText } = panelData.$cache;
switch (state) {
case FloatState.IDLE:
panelData.result = { success: 0, total: 0, error: null, startTime: 0 };
break;
case FloatState.LLM:
el.classList.add('working');
panelData.result.startTime = Date.now();
if (statusIcon) { statusIcon.textContent = '⏳'; statusIcon.className = 'nd-status-icon nd-spin'; }
if (statusText) statusText.textContent = '分析';
break;
case FloatState.GEN:
el.classList.add('working');
if (statusIcon) { statusIcon.textContent = '🎨'; statusIcon.className = 'nd-status-icon nd-spin'; }
if (statusText) statusText.textContent = `${data.current || 0}/${data.total || 0}`;
panelData.result.total = data.total || 0;
break;
case FloatState.COOLDOWN:
el.classList.add('cooldown');
if (statusIcon) { statusIcon.textContent = '⏳'; statusIcon.className = 'nd-status-icon nd-spin'; }
startCooldownTimer(panelData, data.duration);
break;
case FloatState.SUCCESS:
el.classList.add('success');
if (statusIcon) { statusIcon.textContent = '✓'; statusIcon.className = 'nd-status-icon'; }
if (statusText) statusText.textContent = `${data.success}/${data.total}`;
panelData.result.success = data.success;
panelData.result.total = data.total;
panelData.autoResetTimer = setTimeout(() => setState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
break;
case FloatState.PARTIAL:
el.classList.add('partial');
if (statusIcon) { statusIcon.textContent = '⚠'; statusIcon.className = 'nd-status-icon'; }
if (statusText) statusText.textContent = `${data.success}/${data.total}`;
panelData.result.success = data.success;
panelData.result.total = data.total;
panelData.autoResetTimer = setTimeout(() => setState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
break;
case FloatState.ERROR:
el.classList.add('error');
if (statusIcon) { statusIcon.textContent = '✗'; statusIcon.className = 'nd-status-icon'; }
if (statusText) statusText.textContent = data.error?.label || '错误';
panelData.result.error = data.error;
panelData.autoResetTimer = setTimeout(() => setState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
break;
}
}
function startCooldownTimer(panelData, duration) {
panelData.cooldownEndTime = Date.now() + duration;
function tick() {
if (!panelData.cooldownEndTime) return;
const remaining = Math.max(0, panelData.cooldownEndTime - Date.now());
const statusText = panelData.$cache?.statusText;
if (statusText) {
statusText.textContent = `${(remaining / 1000).toFixed(1)}s`;
statusText.className = 'nd-status-text nd-countdown';
}
if (remaining <= 0) {
panelData.cooldownRafId = null;
panelData.cooldownEndTime = 0;
return;
}
panelData.cooldownRafId = requestAnimationFrame(tick);
}
panelData.cooldownRafId = requestAnimationFrame(tick);
}
function updateProgress(messageId, current, total) {
const panelData = panelMap.get(messageId);
if (!panelData?.root || panelData.state !== FloatState.GEN) return;
const statusText = panelData.$cache?.statusText;
if (statusText) statusText.textContent = `${current}/${total}`;
}
function updateDetailPopup(messageId) {
const panelData = panelMap.get(messageId);
if (!panelData?.root) return;
const { result: resultEl, errorRow, error: errorEl, time: timeEl } = panelData.$cache;
const { result, state } = panelData;
const elapsed = result.startTime
? ((Date.now() - result.startTime) / 1000).toFixed(1)
: '-';
if (state === FloatState.SUCCESS || state === FloatState.PARTIAL) {
if (resultEl) {
resultEl.textContent = `${result.success}/${result.total} 成功`;
resultEl.className = `nd-detail-value ${state === FloatState.SUCCESS ? 'success' : 'warning'}`;
}
if (errorRow) errorRow.style.display = state === FloatState.PARTIAL ? 'flex' : 'none';
if (errorEl && state === FloatState.PARTIAL) {
errorEl.textContent = `${result.total - result.success} 张失败`;
}
} else if (state === FloatState.ERROR) {
if (resultEl) {
resultEl.textContent = '生成失败';
resultEl.className = 'nd-detail-value error';
}
if (errorRow) errorRow.style.display = 'flex';
if (errorEl) errorEl.textContent = result.error?.desc || '未知错误';
}
if (timeEl) timeEl.textContent = `${elapsed}s`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 事件处理
// ═══════════════════════════════════════════════════════════════════════════
async function handleDrawClick(messageId) {
const panelData = panelMap.get(messageId);
if (!panelData || panelData.state !== FloatState.IDLE) return;
try {
await generateAndInsertImages({
messageId,
onStateChange: (state, data) => {
switch (state) {
case 'llm': setState(messageId, FloatState.LLM); break;
case 'gen': setState(messageId, FloatState.GEN, data); break;
case 'progress': setState(messageId, FloatState.GEN, data); break;
case 'cooldown': setState(messageId, FloatState.COOLDOWN, data); break;
case 'success':
if (data.aborted && data.success === 0) {
setState(messageId, FloatState.IDLE);
} else if (data.aborted || data.success < data.total) {
setState(messageId, FloatState.PARTIAL, data);
} else {
setState(messageId, FloatState.SUCCESS, data);
}
break;
}
}
});
} catch (e) {
console.error('[NovelDraw]', e);
if (e.message === '已取消') {
setState(messageId, FloatState.IDLE);
} else {
setState(messageId, FloatState.ERROR, { error: classifyError(e) });
}
}
}
async function handleAbort(messageId) {
try {
const { abortGeneration } = await import('./novel-draw.js');
if (abortGeneration()) {
setState(messageId, FloatState.IDLE);
toastr?.info?.('已中止');
}
} catch (e) {
console.error('[NovelDraw] 中止失败:', e);
}
}
function bindPanelEvents(panelData) {
const { messageId, root: el } = panelData;
el.querySelector('.nd-btn-draw')?.addEventListener('click', (e) => {
e.stopPropagation();
handleDrawClick(messageId);
});
el.querySelector('.nd-btn-menu')?.addEventListener('click', (e) => {
e.stopPropagation();
el.classList.remove('show-detail');
if (!el.classList.contains('expanded')) {
refreshPresetSelect(messageId);
refreshSizeSelect(messageId);
}
el.classList.toggle('expanded');
});
el.querySelector('.nd-layer-active')?.addEventListener('click', (e) => {
e.stopPropagation();
const state = panelData.state;
if ([FloatState.LLM, FloatState.GEN, FloatState.COOLDOWN].includes(state)) {
handleAbort(messageId);
} else if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(state)) {
updateDetailPopup(messageId);
el.classList.toggle('show-detail');
}
});
panelData.$cache.presetSelect?.addEventListener('change', (e) => {
const settings = getSettings();
settings.selectedParamsPresetId = e.target.value;
saveSettings(settings);
updateAllPresetSelects();
});
panelData.$cache.sizeSelect?.addEventListener('change', (e) => {
const settings = getSettings();
settings.overrideSize = e.target.value;
saveSettings(settings);
updateAllSizeSelects();
});
panelData.$cache.autoToggle?.addEventListener('click', () => {
const settings = getSettings();
settings.mode = settings.mode === 'auto' ? 'manual' : 'auto';
saveSettings(settings);
updateAutoModeUI();
});
el.querySelector('.nd-settings-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
el.classList.remove('expanded');
openNovelDrawSettings();
});
const closeMenu = (e) => {
if (!el.contains(e.target)) {
el.classList.remove('expanded', 'show-detail');
}
};
document.addEventListener('click', closeMenu, { passive: true });
panelData._cleanup = () => {
document.removeEventListener('click', closeMenu);
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 全局更新
// ═══════════════════════════════════════════════════════════════════════════
function updateAllPresetSelects() {
const settings = getSettings();
const presets = settings.paramsPresets || [];
const currentId = settings.selectedParamsPresetId;
const options = presets.map(p => ({
value: p.id ?? '',
label: p.name || 'Unnamed',
}));
panelMap.forEach((data) => {
const select = data.$cache?.presetSelect;
fillSelectOptions(select, options, currentId);
});
}
function updateAllSizeSelects() {
const settings = getSettings();
const current = settings.overrideSize || 'default';
const options = SIZE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }));
panelMap.forEach((data) => {
const select = data.$cache?.sizeSelect;
fillSelectOptions(select, options, current);
});
}
export function updateAutoModeUI() {
const isAuto = getSettings().mode === 'auto';
panelMap.forEach((data) => {
if (!data.root) return;
data.root.classList.toggle('auto-on', isAuto);
data.$cache.autoToggle?.classList.toggle('on', isAuto);
});
}
function refreshPresetSelect(messageId) {
const data = panelMap.get(messageId);
const select = data?.$cache?.presetSelect;
if (select) {
const settings = getSettings();
const presets = settings.paramsPresets || [];
const currentId = settings.selectedParamsPresetId;
const options = presets.map(p => ({
value: p.id ?? '',
label: p.name || 'Unnamed',
}));
fillSelectOptions(select, options, currentId);
}
}
function refreshSizeSelect(messageId) {
const data = panelMap.get(messageId);
const select = data?.$cache?.sizeSelect;
if (select) {
const settings = getSettings();
const current = settings.overrideSize || 'default';
const options = SIZE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }));
fillSelectOptions(select, options, current);
}
}
export function refreshPresetSelectAll() {
updateAllPresetSelects();
}
// ═══════════════════════════════════════════════════════════════════════════
// 面板挂载(懒加载)
// ═══════════════════════════════════════════════════════════════════════════
function mountPanel(messageEl, messageId) {
if (panelMap.has(messageId)) {
const existing = panelMap.get(messageId);
if (existing.root?.isConnected) return existing;
existing._cleanup?.();
panelMap.delete(messageId);
}
injectStyles();
const panelData = createPanelData(messageId);
const panel = createPanelElement(messageId);
panelData.root = panel;
const success = registerToToolbar(messageId, panel, {
position: 'right',
id: `novel-draw-${messageId}`
});
if (!success) {
return null;
}
cacheDOM(panelData);
bindPanelEvents(panelData);
panelMap.set(messageId, panelData);
return panelData;
}
function setupObserver() {
if (observer) return;
observer = new IntersectionObserver((entries) => {
const toMount = [];
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target;
const mid = Number(el.getAttribute('mesid'));
if (pendingCallbacks.has(mid)) {
toMount.push({ el, mid });
pendingCallbacks.delete(mid);
observer.unobserve(el);
}
}
if (toMount.length > 0) {
requestAnimationFrame(() => {
for (const { el, mid } of toMount) {
mountPanel(el, mid);
}
});
}
}, { rootMargin: '300px' });
}
/**
* 确保面板存在
* @param {HTMLElement} messageEl - 消息元素
* @param {number} messageId - 消息 ID
* @param {Object} options
* @param {boolean} options.force - 强制立即挂载,跳过懒加载
*/
export function ensureNovelDrawPanel(messageEl, messageId, options = {}) {
const { force = false } = options;
injectStyles();
if (panelMap.has(messageId)) {
const existing = panelMap.get(messageId);
if (existing.root?.isConnected) return existing;
existing._cleanup?.();
panelMap.delete(messageId);
}
if (force) {
return mountPanel(messageEl, messageId);
}
const rect = messageEl.getBoundingClientRect();
if (rect.top < window.innerHeight + 500 && rect.bottom > -500) {
return mountPanel(messageEl, messageId);
}
setupObserver();
pendingCallbacks.set(messageId, true);
observer.observe(messageEl);
return null;
}
/**
* 为指定消息设置面板状态
* @param {number} messageId - 消息 ID
* @param {string} state - 状态
* @param {Object} data - 附加数据
*/
export function setStateForMessage(messageId, state, data = {}) {
let panelData = panelMap.get(messageId);
if (!panelData?.root?.isConnected) {
const messageEl = document.querySelector(`.mes[mesid="${messageId}"]`);
if (messageEl) {
panelData = ensureNovelDrawPanel(messageEl, messageId, { force: true });
}
}
if (!panelData) {
console.warn(`[NovelDraw] 无法为消息 ${messageId} 设置状态`);
return;
}
setState(messageId, state, data);
}
// ═══════════════════════════════════════════════════════════════════════════
// 清理
// ═══════════════════════════════════════════════════════════════════════════
export function destroyFloatingPanel() {
panelMap.forEach((data, messageId) => {
if (data.autoResetTimer) clearTimeout(data.autoResetTimer);
if (data.cooldownRafId) cancelAnimationFrame(data.cooldownRafId);
data._cleanup?.();
if (data.root) removeFromToolbar(messageId, data.root);
});
panelMap.clear();
pendingCallbacks.clear();
observer?.disconnect();
observer = null;
}
// ═══════════════════════════════════════════════════════════════════════════
// 导出
// ═══════════════════════════════════════════════════════════════════════════
export {
FloatState,
updateProgress,
refreshPresetSelectAll as refreshPresetSelect,
SIZE_OPTIONS,
};