// 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, };