// 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 =>
``
).join('');
}
function buildSizeOptions() {
const settings = getSettings();
const current = settings.overrideSize || 'default';
return SIZE_OPTIONS.map(opt =>
``
).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 = `
`;
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,
};