Add files via upload

This commit is contained in:
RT15548
2025-12-28 00:49:25 +08:00
committed by GitHub
parent 50495bfb50
commit a693c55e50
20 changed files with 12823 additions and 8731 deletions

View File

@@ -0,0 +1,758 @@
// floating-panel.js
// Novel Draw 悬浮面板 - 冷却倒计时优化版(修复版)
import {
openNovelDrawSettings,
generateAndInsertImages,
getSettings,
saveSettings,
isModuleEnabled,
findLastAIMessageId,
classifyError,
ErrorType,
} from './novel-draw.js';
// ═══════════════════════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════════════════════
const FLOAT_POS_KEY = 'xb_novel_float_pos';
const AUTO_RESET_DELAY = 8000;
const FloatState = {
IDLE: 'idle',
LLM: 'llm',
GEN: 'gen',
COOLDOWN: 'cooldown',
SUCCESS: 'success',
PARTIAL: 'partial',
ERROR: 'error',
};
// ═══════════════════════════════════════════════════════════════════════════
// 状态
// ═══════════════════════════════════════════════════════════════════════════
let floatEl = null;
let dragState = null;
let currentState = FloatState.IDLE;
let currentResult = { success: 0, total: 0, error: null, startTime: 0 };
let autoResetTimer = null;
// 冷却倒计时相关
let cooldownTimer = null;
let cooldownEndTime = 0;
// DOM 缓存
let $cache = {};
function cacheDOM() {
if (!floatEl) return;
$cache = {
capsule: floatEl.querySelector('.nd-capsule'),
statusIcon: floatEl.querySelector('#nd-status-icon'),
statusText: floatEl.querySelector('#nd-status-text'),
detailResult: floatEl.querySelector('#nd-detail-result'),
detailErrorRow: floatEl.querySelector('#nd-detail-error-row'),
detailError: floatEl.querySelector('#nd-detail-error'),
detailTime: floatEl.querySelector('#nd-detail-time'),
presetSelect: floatEl.querySelector('#nd-preset-select'),
autoDot: floatEl.querySelector('#nd-menu-auto-dot'),
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 样式
// ═══════════════════════════════════════════════════════════════════════════
const STYLES = `
:root {
--nd-w: 74px; --nd-h: 34px;
--nd-bg: rgba(28,28,32,0.96);
--nd-border: rgba(255,255,255,0.12);
--nd-accent: #d4a574;
--nd-success: #3ecf8e;
--nd-warning: #f0b429;
--nd-error: #f87171;
--nd-cooldown: #60a5fa;
}
.nd-float { position: fixed; z-index: 10000; user-select: none; }
.nd-capsule {
width: var(--nd-w); height: var(--nd-h);
background: var(--nd-bg);
border: 1px solid var(--nd-border);
border-radius: 17px;
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
position: relative; overflow: hidden;
transition: all 0.25s ease;
touch-action: none; cursor: grab;
}
.nd-capsule:active { cursor: grabbing; }
.nd-float:hover .nd-capsule {
border-color: rgba(255,255,255,0.25);
box-shadow: 0 6px 20px rgba(0,0,0,0.45);
transform: translateY(-1px);
}
/* 状态边框 */
.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.08); }
.nd-float.success .nd-capsule { border-color: rgba(62,207,142,0.6); background: rgba(62,207,142,0.08); }
.nd-float.partial .nd-capsule { border-color: rgba(240,180,41,0.6); background: rgba(240,180,41,0.08); }
.nd-float.error .nd-capsule { border-color: rgba(248,113,113,0.6); background: rgba(248,113,113,0.08); }
/* 层叠 */
.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: rgba(255,255,255,0.9); transition: background 0.15s;
font-size: 16px;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.nd-btn-draw:hover { background: rgba(255,255,255,0.08); }
.nd-btn-draw:active { background: rgba(255,255,255,0.12); }
.nd-auto-dot {
position: absolute; top: 7px; right: 6px; width: 6px; height: 6px;
background: var(--nd-success); border-radius: 50%;
box-shadow: 0 0 4px 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: 14px; background: rgba(255,255,255,0.1); }
.nd-btn-menu {
width: 28px; height: 100%; border: none; background: transparent;
cursor: pointer; display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,0.4); font-size: 8px; transition: all 0.15s;
}
.nd-btn-menu:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); }
.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;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.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-cooldown); }
.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); }
/* 🔧 修复1旋转动画 */
.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; bottom: calc(100% + 8px); left: 50%;
transform: translateX(-50%) translateY(4px);
background: rgba(20,20,24,0.98);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px; padding: 10px 14px;
font-size: 11px; color: rgba(255,255,255,0.8);
white-space: nowrap;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
opacity: 0; visibility: hidden;
transition: all 0.15s ease; z-index: 10;
}
.nd-detail::after {
content: ''; position: absolute; bottom: -5px; left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(20,20,24,0.98);
}
.nd-float.show-detail .nd-detail {
opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0);
}
.nd-detail-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
.nd-detail-row + .nd-detail-row {
margin-top: 4px; padding-top: 6px;
border-top: 1px solid rgba(255,255,255,0.08);
}
.nd-detail-icon { opacity: 0.6; }
.nd-detail-label { color: rgba(255,255,255,0.5); }
.nd-detail-value { margin-left: auto; font-weight: 600; }
.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; bottom: calc(100% + 8px); right: 0;
width: 180px; background: rgba(28,28,32,0.98);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px; padding: 6px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
opacity: 0; visibility: hidden;
transform: translateY(6px) scale(0.98);
transform-origin: bottom right;
transition: all 0.15s cubic-bezier(0.34,1.56,0.64,1);
z-index: 5;
}
.nd-float.expanded .nd-menu {
opacity: 1; visibility: visible; transform: translateY(0) scale(1);
}
.nd-menu-header {
padding: 6px 10px 4px;
font-size: 10px;
color: rgba(255,255,255,0.35);
}
.nd-menu-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; color: rgba(255,255,255,0.75);
font-size: 12px; transition: background 0.1s;
}
.nd-menu-item:hover { background: rgba(255,255,255,0.08); }
.nd-menu-item.active { color: var(--accent); }
.nd-item-icon { width: 14px; text-align: center; font-size: 10px; opacity: 0.5; }
.nd-menu-item.active .nd-item-icon { opacity: 1; }
.nd-menu-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 4px 0; }
.nd-menu-dot {
width: 6px; height: 6px; border-radius: 50%;
background: rgba(255,255,255,0.2); margin-left: auto; transition: all 0.2s;
}
.nd-menu-dot.active {
background: var(--nd-success);
box-shadow: 0 0 6px rgba(62,207,142,0.6);
}
/* 预设下拉框 */
.nd-preset-row { padding: 4px 10px 8px; }
.nd-preset-select {
width: 100%; padding: 6px 8px;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: rgba(255,255,255,0.9);
font-size: 12px; cursor: pointer; outline: none;
transition: border-color 0.15s;
}
.nd-preset-select:hover { border-color: rgba(255,255,255,0.25); }
.nd-preset-select:focus { border-color: var(--nd-accent); }
.nd-preset-select option { background: #1a1a1e; color: #fff; }
`;
function injectStyles() {
if (document.getElementById('nd-float-styles')) return;
const el = document.createElement('style');
el.id = 'nd-float-styles';
el.textContent = STYLES;
document.head.appendChild(el);
}
// ═══════════════════════════════════════════════════════════════════════════
// 位置管理
// ═══════════════════════════════════════════════════════════════════════════
function getPosition() {
try {
const raw = localStorage.getItem(FLOAT_POS_KEY);
if (raw) return JSON.parse(raw);
} catch {}
const debug = document.getElementById('xiaobaix-debug-mini');
if (debug) {
const r = debug.getBoundingClientRect();
return { left: r.left, top: r.bottom + 8 };
}
return { left: window.innerWidth - 110, top: window.innerHeight - 80 };
}
function savePosition() {
if (!floatEl) return;
const r = floatEl.getBoundingClientRect();
try {
localStorage.setItem(FLOAT_POS_KEY, JSON.stringify({
left: Math.round(r.left),
top: Math.round(r.top)
}));
} catch {}
}
function applyPosition() {
if (!floatEl) return;
const pos = getPosition();
const w = floatEl.offsetWidth || 77;
const h = floatEl.offsetHeight || 34;
floatEl.style.left = `${Math.max(0, Math.min(pos.left, window.innerWidth - w))}px`;
floatEl.style.top = `${Math.max(0, Math.min(pos.top, window.innerHeight - h))}px`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 冷却倒计时
// ═══════════════════════════════════════════════════════════════════════════
function clearCooldownTimer() {
if (cooldownTimer) {
clearInterval(cooldownTimer);
cooldownTimer = null;
}
cooldownEndTime = 0;
}
function startCooldownTimer(duration) {
clearCooldownTimer();
cooldownEndTime = Date.now() + duration;
// 立即更新一次
updateCooldownDisplay();
// 🔧 修复3每50ms更新一次更流畅且始终更新显示
cooldownTimer = setInterval(() => {
updateCooldownDisplay();
// 倒计时结束后清理定时器(但不切换状态,等 novel-draw.js 来切换)
if (cooldownEndTime - Date.now() <= -100) {
clearCooldownTimer();
}
}, 50);
}
function updateCooldownDisplay() {
const { statusIcon, statusText } = $cache;
if (!statusIcon || !statusText) return;
// 🔧 修复2 & 3显示小数点后一位最小显示0.0
const remaining = Math.max(0, cooldownEndTime - Date.now());
const seconds = (remaining / 1000).toFixed(1);
statusText.textContent = `${seconds}s`;
statusText.className = 'nd-countdown';
}
// ═══════════════════════════════════════════════════════════════════════════
// 状态管理
// ═══════════════════════════════════════════════════════════════════════════
// 🔧 修复1spinning 设为 true
const STATE_CONFIG = {
[FloatState.IDLE]: { cls: '', icon: '', text: '', spinning: false },
[FloatState.LLM]: { cls: 'working', icon: '⏳', text: '分析', spinning: true },
[FloatState.GEN]: { cls: 'working', icon: '🎨', text: '', spinning: true },
[FloatState.COOLDOWN]: { cls: 'cooldown', icon: '⏳', text: '', spinning: true },
[FloatState.SUCCESS]: { cls: 'success', icon: '✓', text: '', spinning: false },
[FloatState.PARTIAL]: { cls: 'partial', icon: '⚠', text: '', spinning: false },
[FloatState.ERROR]: { cls: 'error', icon: '✗', text: '', spinning: false },
};
function setState(state, data = {}) {
if (!floatEl) return;
currentState = state;
// 清理自动重置定时器
if (autoResetTimer) {
clearTimeout(autoResetTimer);
autoResetTimer = null;
}
// 非冷却状态时清理冷却定时器
if (state !== FloatState.COOLDOWN) {
clearCooldownTimer();
}
// 移除所有状态类
floatEl.classList.remove('working', 'cooldown', 'success', 'partial', 'error', 'show-detail');
const cfg = STATE_CONFIG[state];
if (cfg.cls) floatEl.classList.add(cfg.cls);
const { statusIcon, statusText } = $cache;
if (!statusIcon || !statusText) return;
// 🔧 修复1根据 spinning 添加旋转类
statusIcon.textContent = cfg.icon;
statusIcon.className = cfg.spinning ? 'nd-spin' : '';
statusText.className = '';
switch (state) {
case FloatState.IDLE:
currentResult = { success: 0, total: 0, error: null, startTime: 0 };
break;
case FloatState.LLM:
currentResult.startTime = Date.now();
statusText.textContent = cfg.text;
break;
case FloatState.GEN:
statusText.textContent = `${data.current || 0}/${data.total || 0}`;
currentResult.total = data.total || 0;
break;
case FloatState.COOLDOWN:
// 启动冷却倒计时
startCooldownTimer(data.duration);
break;
case FloatState.SUCCESS:
case FloatState.PARTIAL:
statusText.textContent = `${data.success}/${data.total}`;
currentResult.success = data.success;
currentResult.total = data.total;
autoResetTimer = setTimeout(() => setState(FloatState.IDLE), AUTO_RESET_DELAY);
break;
case FloatState.ERROR:
statusText.textContent = data.error?.label || '错误';
currentResult.error = data.error;
autoResetTimer = setTimeout(() => setState(FloatState.IDLE), AUTO_RESET_DELAY);
break;
}
}
function updateProgress(current, total) {
if (currentState !== FloatState.GEN || !$cache.statusText) return;
$cache.statusText.textContent = `${current}/${total}`;
}
function updateDetailPopup() {
const { detailResult, detailErrorRow, detailError, detailTime } = $cache;
if (!detailResult) return;
const elapsed = currentResult.startTime
? ((Date.now() - currentResult.startTime) / 1000).toFixed(1)
: '-';
const isSuccess = currentState === FloatState.SUCCESS;
const isPartial = currentState === FloatState.PARTIAL;
const isError = currentState === FloatState.ERROR;
if (isSuccess || isPartial) {
detailResult.textContent = `${currentResult.success}/${currentResult.total} 成功`;
detailResult.className = `nd-detail-value ${isSuccess ? 'success' : 'warning'}`;
detailErrorRow.style.display = isPartial ? 'flex' : 'none';
if (isPartial) detailError.textContent = `${currentResult.total - currentResult.success} 张失败`;
} else if (isError) {
detailResult.textContent = '生成失败';
detailResult.className = 'nd-detail-value error';
detailErrorRow.style.display = 'flex';
detailError.textContent = currentResult.error?.desc || '未知错误';
}
detailTime.textContent = `${elapsed}s`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 拖拽与点击
// ═══════════════════════════════════════════════════════════════════════════
function onPointerDown(e) {
if (e.button !== 0) return;
dragState = {
startX: e.clientX,
startY: e.clientY,
startLeft: floatEl.getBoundingClientRect().left,
startTop: floatEl.getBoundingClientRect().top,
pointerId: e.pointerId,
moved: false,
originalTarget: e.target
};
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
e.preventDefault();
}
function onPointerMove(e) {
if (!dragState || dragState.pointerId !== e.pointerId) return;
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
dragState.moved = true;
}
if (dragState.moved) {
const w = floatEl.offsetWidth || 88;
const h = floatEl.offsetHeight || 36;
floatEl.style.left = `${Math.max(0, Math.min(dragState.startLeft + dx, window.innerWidth - w))}px`;
floatEl.style.top = `${Math.max(0, Math.min(dragState.startTop + dy, window.innerHeight - h))}px`;
}
e.preventDefault();
}
function onPointerUp(e) {
if (!dragState || dragState.pointerId !== e.pointerId) return;
const { moved, originalTarget } = dragState;
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
dragState = null;
if (moved) {
savePosition();
} else {
routeClick(originalTarget);
}
}
function routeClick(target) {
if (target.closest('#nd-btn-draw')) {
handleDrawClick();
} else if (target.closest('#nd-btn-menu')) {
floatEl.classList.remove('show-detail');
if (!floatEl.classList.contains('expanded')) {
refreshPresetSelect();
}
floatEl.classList.toggle('expanded');
} else if (target.closest('#nd-layer-active')) {
if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(currentState)) {
updateDetailPopup();
floatEl.classList.toggle('show-detail');
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 核心操作
// ═══════════════════════════════════════════════════════════════════════════
async function handleDrawClick() {
if (currentState !== FloatState.IDLE) return;
const messageId = findLastAIMessageId();
if (messageId < 0) {
toastr?.warning?.('没有可配图的AI消息');
return;
}
try {
await generateAndInsertImages({
messageId,
onStateChange: (state, data) => {
switch (state) {
case 'llm':
setState(FloatState.LLM);
break;
case 'gen':
setState(FloatState.GEN, data);
break;
case 'progress':
setState(FloatState.GEN, data); // 用 GEN 状态显示进度
break;
case 'cooldown':
setState(FloatState.COOLDOWN, data);
break;
case 'success':
setState(data.success === data.total ? FloatState.SUCCESS : FloatState.PARTIAL, data);
break;
}
}
});
} catch (e) {
console.error('[NovelDraw]', e);
setState(FloatState.ERROR, { error: classifyError(e) });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 预设管理
// ═══════════════════════════════════════════════════════════════════════════
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 refreshPresetSelect() {
if (!$cache.presetSelect) return;
$cache.presetSelect.innerHTML = buildPresetOptions();
}
function handlePresetChange(e) {
const presetId = e.target.value;
if (!presetId) return;
const settings = getSettings();
settings.selectedParamsPresetId = presetId;
saveSettings(settings);
}
export function updateAutoModeUI() {
if (!floatEl) return;
const isAuto = getSettings().mode === 'auto';
floatEl.classList.toggle('auto-on', isAuto);
const menuDot = floatEl.querySelector('#nd-menu-auto-dot');
menuDot?.classList.toggle('active', isAuto);
}
function handleAutoToggle() {
const settings = getSettings();
settings.mode = settings.mode === 'auto' ? 'manual' : 'auto';
saveSettings(settings);
updateAutoModeUI();
}
// ═══════════════════════════════════════════════════════════════════════════
// 创建与销毁
// ═══════════════════════════════════════════════════════════════════════════
export function createFloatingPanel() {
if (floatEl) return;
injectStyles();
const settings = getSettings();
const isAuto = settings.mode === 'auto';
floatEl = document.createElement('div');
floatEl.className = `nd-float${isAuto ? ' auto-on' : ''}`;
floatEl.id = 'nd-floating-panel';
floatEl.innerHTML = `
<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" id="nd-detail-result">-</span>
</div>
<div class="nd-detail-row" id="nd-detail-error-row" style="display:none">
<span class="nd-detail-icon">💡</span>
<span class="nd-detail-label">原因</span>
<span class="nd-detail-value error" id="nd-detail-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" id="nd-detail-time">-</span>
</div>
</div>
<div class="nd-menu">
<div class="nd-menu-header">画风预设</div>
<div class="nd-preset-row">
<select class="nd-preset-select" id="nd-preset-select">
${buildPresetOptions()}
</select>
</div>
<div class="nd-menu-divider"></div>
<div class="nd-menu-item" id="nd-menu-auto">
<span class="nd-item-icon">🔄</span>
<span>自动配图</span>
<span class="nd-menu-dot${isAuto ? ' active' : ''}" id="nd-menu-auto-dot"></span>
</div>
<div class="nd-menu-divider"></div>
<div class="nd-menu-item" id="nd-menu-settings">
<span class="nd-item-icon">⚙️</span>
<span>设置</span>
</div>
</div>
<div class="nd-capsule">
<div class="nd-inner">
<div class="nd-layer nd-layer-idle">
<button class="nd-btn-draw" id="nd-btn-draw" title="点击生成配图">
<span>🎨</span>
<span class="nd-auto-dot"></span>
</button>
<div class="nd-sep"></div>
<button class="nd-btn-menu" id="nd-btn-menu" title="展开菜单">
<span class="nd-arrow">▲</span>
</button>
</div>
<div class="nd-layer nd-layer-active" id="nd-layer-active">
<span id="nd-status-icon">⏳</span>
<span id="nd-status-text">分析</span>
</div>
</div>
</div>
`;
document.body.appendChild(floatEl);
cacheDOM();
applyPosition();
bindEvents();
window.addEventListener('resize', applyPosition);
}
function bindEvents() {
const capsule = $cache.capsule;
if (!capsule) return;
capsule.addEventListener('pointerdown', onPointerDown, { passive: false });
capsule.addEventListener('pointermove', onPointerMove, { passive: false });
capsule.addEventListener('pointerup', onPointerUp, { passive: false });
capsule.addEventListener('pointercancel', onPointerUp, { passive: false });
$cache.presetSelect?.addEventListener('change', handlePresetChange);
floatEl.querySelector('#nd-menu-auto')?.addEventListener('click', handleAutoToggle);
floatEl.querySelector('#nd-menu-settings')?.addEventListener('click', () => {
floatEl.classList.remove('expanded');
openNovelDrawSettings();
});
document.addEventListener('click', (e) => {
if (!floatEl.contains(e.target)) {
floatEl.classList.remove('expanded', 'show-detail');
}
});
}
export function destroyFloatingPanel() {
clearCooldownTimer();
if (autoResetTimer) {
clearTimeout(autoResetTimer);
autoResetTimer = null;
}
window.removeEventListener('resize', applyPosition);
floatEl?.remove();
floatEl = null;
dragState = null;
currentState = FloatState.IDLE;
$cache = {};
}
// ═══════════════════════════════════════════════════════════════════════════
// 导出
// ═══════════════════════════════════════════════════════════════════════════
export { FloatState, setState, updateProgress, refreshPresetSelect };