diff --git a/modules/novel-draw/floating-panel.js b/modules/novel-draw/floating-panel.js index 0a3ec25..3ff7929 100644 --- a/modules/novel-draw/floating-panel.js +++ b/modules/novel-draw/floating-panel.js @@ -1,14 +1,14 @@ -// floating-panel.js +// floating-panel.js /** - * NovelDraw 画图按钮面板 - * 和 TTS 播放器一样,每条 AI 消息都有一个 + * NovelDraw 画图按钮面板 - 支持楼层按钮和悬浮按钮双模式 */ -import { +import { openNovelDrawSettings, generateAndInsertImages, getSettings, saveSettings, + findLastAIMessageId, classifyError, } from './novel-draw.js'; import { registerToToolbar, removeFromToolbar } from '../../widgets/message-toolbar.js'; @@ -17,6 +17,7 @@ import { registerToToolbar, removeFromToolbar } from '../../widgets/message-tool // 常量 // ═══════════════════════════════════════════════════════════════════════════ +const FLOAT_POS_KEY = 'xb_novel_float_pos'; const AUTO_RESET_DELAY = 8000; const FloatState = { @@ -39,22 +40,36 @@ const SIZE_OPTIONS = [ ]; // ═══════════════════════════════════════════════════════════════════════════ -// 状态(每条消息独立) +// 状态 // ═══════════════════════════════════════════════════════════════════════════ -const panelMap = new Map(); // messageId -> panelData -const pendingCallbacks = new Map(); // messageId -> true -let observer = null; +// 楼层按钮状态 +const panelMap = new Map(); +const pendingCallbacks = new Map(); +let floorObserver = null; + +// 悬浮按钮状态 +let floatingEl = null; +let floatingDragState = null; +let floatingState = FloatState.IDLE; +let floatingResult = { success: 0, total: 0, error: null, startTime: 0 }; +let floatingAutoResetTimer = null; +let floatingCooldownRafId = null; +let floatingCooldownEndTime = 0; +let $floatingCache = {}; + +// 通用状态 let stylesInjected = false; // ═══════════════════════════════════════════════════════════════════════════ -// 样式 - 菜单向下展开 +// 样式 - 统一样式(楼层+悬浮共用) // ═══════════════════════════════════════════════════════════════════════════ const STYLES = ` :root { --nd-h: 34px; --nd-bg: rgba(0, 0, 0, 0.55); + --nd-bg-solid: rgba(24, 24, 28, 0.98); --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); @@ -74,8 +89,11 @@ const STYLES = ` --nd-radius-lg: 14px; } -.nd-float { - position: relative; +/* ═══════════════════════════════════════════════════════════════════════════ + 楼层按钮样式 + ═══════════════════════════════════════════════════════════════════════════ */ +.nd-float { + position: relative; user-select: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } @@ -104,22 +122,22 @@ const STYLES = ` .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-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 { + 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); } @@ -129,8 +147,8 @@ const STYLES = ` .nd-float.success .nd-layer-idle, .nd-float.partial .nd-layer-idle, .nd-float.error .nd-layer-idle { - opacity: 0; - transform: translateY(-100%); + opacity: 0; + transform: translateY(-100%); pointer-events: none; } @@ -220,14 +238,12 @@ const STYLES = ` .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); + background: rgba(18, 18, 22, 0.98); border: 1px solid var(--nd-border); border-radius: 12px; padding: 12px 16px; @@ -260,9 +276,7 @@ const STYLES = ` .nd-detail-value.warning { color: var(--nd-warning); } .nd-detail-value.error { color: var(--nd-error); } -/* ═══════════════════════════════════════════════════════════════════════════ - 菜单 - 向下展开 - ═══════════════════════════════════════════════════════════════════════════ */ +/* 菜单 - 向下展开(楼层按钮用) */ .nd-menu { position: absolute; top: calc(100% + 8px); @@ -296,7 +310,13 @@ const STYLES = ` overflow: visible; } -.nd-row { display: flex; align-items: center; gap: 10px; padding: 6px 2px; } +.nd-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 2px; + min-height: 36px; +} .nd-label { font-size: 11px; @@ -313,6 +333,7 @@ const STYLES = ` border: 1px solid var(--nd-border-subtle); color: var(--nd-text-primary); font-size: 11px; + min-height: 32px; border-radius: 6px; padding: 6px 8px; margin: 0; @@ -322,6 +343,7 @@ const STYLES = ` text-align: center; text-align-last: center; transition: border-color 0.2s; + vertical-align: middle; -webkit-appearance: none; -moz-appearance: none; appearance: none; @@ -378,12 +400,70 @@ const STYLES = ` transition: all 0.15s; } .nd-gear:hover { background: rgba(255, 255, 255, 0.08); color: var(--nd-text-secondary); } + +/* ═══════════════════════════════════════════════════════════════════════════ + 悬浮按钮样式(固定定位,可拖拽) + ═══════════════════════════════════════════════════════════════════════════ */ +.nd-floating-global { + position: fixed; + z-index: 10000; + user-select: none; + will-change: transform; +} + +.nd-floating-global .nd-capsule { + background: var(--nd-bg-solid); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + touch-action: none; + cursor: grab; +} + +.nd-floating-global .nd-capsule:active { cursor: grabbing; } + +/* 悬浮按钮的详情和菜单向上展开 */ +.nd-floating-global .nd-detail { + top: auto; + bottom: calc(100% + 10px); + transform: translateY(4px) scale(0.96); + transform-origin: bottom right; +} + +.nd-floating-global.show-detail .nd-detail { + transform: translateY(0) scale(1); +} + +.nd-floating-global .nd-detail::after { + content: ''; + position: absolute; + top: auto; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: rgba(18, 18, 22, 0.98); + border-bottom-color: transparent; +} + +.nd-floating-global .nd-menu { + top: auto; + bottom: calc(100% + 10px); + transform: translateY(6px) scale(0.98); + transform-origin: bottom right; +} + +.nd-floating-global.expanded .nd-menu { + transform: translateY(0) scale(1); +} + +/* 悬浮按钮箭头向上 */ +.nd-floating-global .nd-arrow { transform: rotate(180deg); } +.nd-floating-global.expanded .nd-arrow { transform: rotate(0deg); } `; function injectStyles() { if (stylesInjected) return; stylesInjected = true; - + const el = document.createElement('style'); el.id = 'nd-float-styles'; el.textContent = STYLES; @@ -391,10 +471,50 @@ function injectStyles() { } // ═══════════════════════════════════════════════════════════════════════════ -// 面板数据结构 +// 通用工具函数 // ═══════════════════════════════════════════════════════════════════════════ -function createPanelData(messageId) { +function createEl(tag, className, text) { + const el = document.createElement(tag); + if (className) el.className = className; + if (text !== undefined) el.textContent = text; + return el; +} + +function fillPresetSelect(selectEl) { + if (!selectEl) return; + const settings = getSettings(); + const presets = settings.paramsPresets || []; + const currentId = settings.selectedParamsPresetId; + selectEl.replaceChildren(); + presets.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.name || '未命名'; + if (p.id === currentId) opt.selected = true; + selectEl.appendChild(opt); + }); +} + +function fillSizeSelect(selectEl) { + if (!selectEl) return; + const settings = getSettings(); + const current = settings.overrideSize || 'default'; + selectEl.replaceChildren(); + SIZE_OPTIONS.forEach(opt => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === current) option.selected = true; + selectEl.appendChild(option); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ▼▼▼ 楼层按钮逻辑 ▼▼▼ +// ═══════════════════════════════════════════════════════════════════════════ + +function createFloorPanelData(messageId) { return { messageId, root: null, @@ -408,125 +528,93 @@ function createPanelData(messageId) { }; } -// ═══════════════════════════════════════════════════════════════════════════ -// 面板创建 - 箭头改为向下 ▼ -// ═══════════════════════════════════════════════════════════════════════════ - -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) { +function createFloorPanelElement(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; + + const root = document.createElement('div'); + root.className = `nd-float${isAuto ? ' auto-on' : ''}`; + root.dataset.messageId = messageId; + + const capsule = createEl('div', 'nd-capsule'); + const inner = createEl('div', 'nd-inner'); + const layerIdle = createEl('div', 'nd-layer nd-layer-idle'); + const drawBtn = createEl('button', 'nd-btn-draw'); + drawBtn.title = '点击生成配图'; + drawBtn.appendChild(createEl('span', '', '🎨')); + drawBtn.appendChild(createEl('span', 'nd-auto-dot')); + const sep = createEl('div', 'nd-sep'); + const menuBtn = createEl('button', 'nd-btn-menu'); + menuBtn.title = '展开菜单'; + menuBtn.appendChild(createEl('span', 'nd-arrow', '▼')); + layerIdle.append(drawBtn, sep, menuBtn); + + const layerActive = createEl('div', 'nd-layer nd-layer-active'); + layerActive.append( + createEl('span', 'nd-status-icon', '⏳'), + createEl('span', 'nd-status-text', '分析') + ); + + inner.append(layerIdle, layerActive); + capsule.appendChild(inner); + + const detail = createEl('div', 'nd-detail'); + const detailRowResult = createEl('div', 'nd-detail-row'); + detailRowResult.append( + createEl('span', 'nd-detail-icon', '📊'), + createEl('span', 'nd-detail-label', '结果'), + createEl('span', 'nd-detail-value nd-result', '-') + ); + const detailRowError = createEl('div', 'nd-detail-row nd-error-row'); + detailRowError.style.display = 'none'; + detailRowError.append( + createEl('span', 'nd-detail-icon', '💡'), + createEl('span', 'nd-detail-label', '原因'), + createEl('span', 'nd-detail-value error nd-error', '-') + ); + const detailRowTime = createEl('div', 'nd-detail-row'); + detailRowTime.append( + createEl('span', 'nd-detail-icon', '⏱'), + createEl('span', 'nd-detail-label', '耗时'), + createEl('span', 'nd-detail-value nd-time', '-') + ); + detail.append(detailRowResult, detailRowError, detailRowTime); + + const menu = createEl('div', 'nd-menu'); + const card = createEl('div', 'nd-card'); + const rowPreset = createEl('div', 'nd-row'); + rowPreset.appendChild(createEl('span', 'nd-label', '预设')); + const presetSelect = createEl('select', 'nd-select nd-preset-select'); + fillPresetSelect(presetSelect); + rowPreset.appendChild(presetSelect); + const innerSep = createEl('div', 'nd-inner-sep'); + const rowSize = createEl('div', 'nd-row'); + rowSize.appendChild(createEl('span', 'nd-label', '尺寸')); + const sizeSelect = createEl('select', 'nd-select size nd-size-select'); + fillSizeSelect(sizeSelect); + rowSize.appendChild(sizeSelect); + card.append(rowPreset, innerSep, rowSize); + + const controls = createEl('div', 'nd-controls'); + const autoToggle = createEl('div', `nd-auto${isAuto ? ' on' : ''} nd-auto-toggle`); + autoToggle.append( + createEl('span', 'nd-dot'), + createEl('span', 'nd-auto-text', '自动配图') + ); + const settingsBtn = createEl('button', 'nd-gear nd-settings-btn', '⚙'); + settingsBtn.title = '打开设置'; + controls.append(autoToggle, settingsBtn); + + menu.append(card, controls); + + root.append(capsule, detail, menu); + return root; } -function cacheDOM(panelData) { +function cacheFloorDOM(panelData) { const el = panelData.root; if (!el) return; - + panelData.$cache = { statusIcon: el.querySelector('.nd-status-icon'), statusText: el.querySelector('.nd-status-text'), @@ -540,18 +628,13 @@ function cacheDOM(panelData) { }; } -// ═══════════════════════════════════════════════════════════════════════════ -// 状态管理(每个面板独立) -// ═══════════════════════════════════════════════════════════════════════════ - -function setState(messageId, state, data = {}) { +function setFloorState(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; @@ -561,12 +644,11 @@ function setState(messageId, state, data = {}) { 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 }; @@ -586,7 +668,7 @@ function setState(messageId, state, data = {}) { case FloatState.COOLDOWN: el.classList.add('cooldown'); if (statusIcon) { statusIcon.textContent = '⏳'; statusIcon.className = 'nd-status-icon nd-spin'; } - startCooldownTimer(panelData, data.duration); + startFloorCooldownTimer(panelData, data.duration); break; case FloatState.SUCCESS: el.classList.add('success'); @@ -594,7 +676,7 @@ function setState(messageId, state, data = {}) { 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); + panelData.autoResetTimer = setTimeout(() => setFloorState(messageId, FloatState.IDLE), AUTO_RESET_DELAY); break; case FloatState.PARTIAL: el.classList.add('partial'); @@ -602,21 +684,21 @@ function setState(messageId, state, data = {}) { 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); + panelData.autoResetTimer = setTimeout(() => setFloorState(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); + panelData.autoResetTimer = setTimeout(() => setFloorState(messageId, FloatState.IDLE), AUTO_RESET_DELAY); break; } } -function startCooldownTimer(panelData, duration) { +function startFloorCooldownTimer(panelData, duration) { panelData.cooldownEndTime = Date.now() + duration; - + function tick() { if (!panelData.cooldownEndTime) return; const remaining = Math.max(0, panelData.cooldownEndTime - Date.now()); @@ -632,28 +714,21 @@ function startCooldownTimer(panelData, duration) { } 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) { +function updateFloorDetailPopup(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) + + 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} 成功`; @@ -671,34 +746,30 @@ function updateDetailPopup(messageId) { if (errorRow) errorRow.style.display = 'flex'; if (errorEl) errorEl.textContent = result.error?.desc || '未知错误'; } - + if (timeEl) timeEl.textContent = `${elapsed}s`; } -// ═══════════════════════════════════════════════════════════════════════════ -// 事件处理 -// ═══════════════════════════════════════════════════════════════════════════ - -async function handleDrawClick(messageId) { +async function handleFloorDrawClick(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 'llm': setFloorState(messageId, FloatState.LLM); break; + case 'gen': setFloorState(messageId, FloatState.GEN, data); break; + case 'progress': setFloorState(messageId, FloatState.GEN, data); break; + case 'cooldown': setFloorState(messageId, FloatState.COOLDOWN, data); break; case 'success': if (data.aborted && data.success === 0) { - setState(messageId, FloatState.IDLE); + setFloorState(messageId, FloatState.IDLE); } else if (data.aborted || data.success < data.total) { - setState(messageId, FloatState.PARTIAL, data); + setFloorState(messageId, FloatState.PARTIAL, data); } else { - setState(messageId, FloatState.SUCCESS, data); + setFloorState(messageId, FloatState.SUCCESS, data); } break; } @@ -707,18 +778,18 @@ async function handleDrawClick(messageId) { } catch (e) { console.error('[NovelDraw]', e); if (e.message === '已取消') { - setState(messageId, FloatState.IDLE); + setFloorState(messageId, FloatState.IDLE); } else { - setState(messageId, FloatState.ERROR, { error: classifyError(e) }); + setFloorState(messageId, FloatState.ERROR, { error: classifyError(e) }); } } } -async function handleAbort(messageId) { +async function handleFloorAbort(messageId) { try { const { abortGeneration } = await import('./novel-draw.js'); if (abortGeneration()) { - setState(messageId, FloatState.IDLE); + setFloorState(messageId, FloatState.IDLE); toastr?.info?.('已中止'); } } catch (e) { @@ -726,134 +797,686 @@ async function handleAbort(messageId) { } } -function bindPanelEvents(panelData) { +function bindFloorPanelEvents(panelData) { const { messageId, root: el } = panelData; - + el.querySelector('.nd-btn-draw')?.addEventListener('click', (e) => { e.stopPropagation(); - handleDrawClick(messageId); + handleFloorDrawClick(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); + refreshFloorPresetSelect(messageId); + refreshFloorSizeSelect(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); + handleFloorAbort(messageId); } else if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(state)) { - updateDetailPopup(messageId); + updateFloorDetailPopup(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 refreshFloorPresetSelect(messageId) { + const data = panelMap.get(messageId); + const select = data?.$cache?.presetSelect; + fillPresetSelect(select); +} + +function refreshFloorSizeSelect(messageId) { + const data = panelMap.get(messageId); + const select = data?.$cache?.sizeSelect; + fillSizeSelect(select); +} + +function mountFloorPanel(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 = createFloorPanelData(messageId); + const panel = createFloorPanelElement(messageId); + panelData.root = panel; + + const success = registerToToolbar(messageId, panel, { + position: 'right', + id: `novel-draw-${messageId}` + }); + + if (!success) return null; + + cacheFloorDOM(panelData); + bindFloorPanelEvents(panelData); + + panelMap.set(messageId, panelData); + return panelData; +} + +function setupFloorObserver() { + if (floorObserver) return; + + floorObserver = 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); + floorObserver.unobserve(el); + } + } + + if (toMount.length > 0) { + requestAnimationFrame(() => { + for (const { el, mid } of toMount) { + mountFloorPanel(el, mid); + } + }); + } + }, { rootMargin: '300px' }); +} + +export function ensureNovelDrawPanel(messageEl, messageId, options = {}) { + const settings = getSettings(); + if (settings.showFloorButton === false) return null; + + 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 mountFloorPanel(messageEl, messageId); + } + + const rect = messageEl.getBoundingClientRect(); + if (rect.top < window.innerHeight + 500 && rect.bottom > -500) { + return mountFloorPanel(messageEl, messageId); + } + + setupFloorObserver(); + pendingCallbacks.set(messageId, true); + floorObserver.observe(messageEl); + + return null; +} + +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) { + setFloorState(messageId, state, data); + } + + if (floatingEl && messageId === findLastAIMessageId()) { + setFloatingState(state, data); + } +} + // ═══════════════════════════════════════════════════════════════════════════ -// 全局更新 +// ▼▼▼ 悬浮按钮逻辑 ▼▼▼ +// ═══════════════════════════════════════════════════════════════════════════ + +function getFloatingPosition() { + 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 saveFloatingPosition() { + if (!floatingEl) return; + const r = floatingEl.getBoundingClientRect(); + try { + localStorage.setItem(FLOAT_POS_KEY, JSON.stringify({ + left: Math.round(r.left), + top: Math.round(r.top) + })); + } catch {} +} + +function applyFloatingPosition() { + if (!floatingEl) return; + const pos = getFloatingPosition(); + const w = floatingEl.offsetWidth || 77; + const h = floatingEl.offsetHeight || 34; + floatingEl.style.left = `${Math.max(0, Math.min(pos.left, window.innerWidth - w))}px`; + floatingEl.style.top = `${Math.max(0, Math.min(pos.top, window.innerHeight - h))}px`; +} + +function clearFloatingCooldownTimer() { + if (floatingCooldownRafId) { + cancelAnimationFrame(floatingCooldownRafId); + floatingCooldownRafId = null; + } + floatingCooldownEndTime = 0; +} + +function startFloatingCooldownTimer(duration) { + clearFloatingCooldownTimer(); + floatingCooldownEndTime = Date.now() + duration; + + function tick() { + if (!floatingCooldownEndTime) return; + const remaining = Math.max(0, floatingCooldownEndTime - Date.now()); + const statusText = $floatingCache.statusText; + if (statusText) { + statusText.textContent = `${(remaining / 1000).toFixed(1)}s`; + statusText.className = 'nd-status-text nd-countdown'; + } + if (remaining <= 0) { + clearFloatingCooldownTimer(); + return; + } + floatingCooldownRafId = requestAnimationFrame(tick); + } + + floatingCooldownRafId = requestAnimationFrame(tick); +} + +function setFloatingState(state, data = {}) { + if (!floatingEl) return; + + floatingState = state; + + if (floatingAutoResetTimer) { + clearTimeout(floatingAutoResetTimer); + floatingAutoResetTimer = null; + } + + if (state !== FloatState.COOLDOWN) { + clearFloatingCooldownTimer(); + } + + floatingEl.classList.remove('working', 'cooldown', 'success', 'partial', 'error', 'show-detail'); + + const { statusIcon, statusText } = $floatingCache; + if (!statusIcon || !statusText) return; + + switch (state) { + case FloatState.IDLE: + floatingResult = { success: 0, total: 0, error: null, startTime: 0 }; + break; + case FloatState.LLM: + floatingEl.classList.add('working'); + floatingResult.startTime = Date.now(); + statusIcon.textContent = '⏳'; + statusIcon.className = 'nd-status-icon nd-spin'; + statusText.textContent = '分析'; + break; + case FloatState.GEN: + floatingEl.classList.add('working'); + statusIcon.textContent = '🎨'; + statusIcon.className = 'nd-status-icon nd-spin'; + statusText.textContent = `${data.current || 0}/${data.total || 0}`; + floatingResult.total = data.total || 0; + break; + case FloatState.COOLDOWN: + floatingEl.classList.add('cooldown'); + statusIcon.textContent = '⏳'; + statusIcon.className = 'nd-status-icon nd-spin'; + startFloatingCooldownTimer(data.duration); + break; + case FloatState.SUCCESS: + floatingEl.classList.add('success'); + statusIcon.textContent = '✓'; + statusIcon.className = 'nd-status-icon'; + statusText.textContent = `${data.success}/${data.total}`; + floatingResult.success = data.success; + floatingResult.total = data.total; + floatingAutoResetTimer = setTimeout(() => setFloatingState(FloatState.IDLE), AUTO_RESET_DELAY); + break; + case FloatState.PARTIAL: + floatingEl.classList.add('partial'); + statusIcon.textContent = '⚠'; + statusIcon.className = 'nd-status-icon'; + statusText.textContent = `${data.success}/${data.total}`; + floatingResult.success = data.success; + floatingResult.total = data.total; + floatingAutoResetTimer = setTimeout(() => setFloatingState(FloatState.IDLE), AUTO_RESET_DELAY); + break; + case FloatState.ERROR: + floatingEl.classList.add('error'); + statusIcon.textContent = '✗'; + statusIcon.className = 'nd-status-icon'; + statusText.textContent = data.error?.label || '错误'; + floatingResult.error = data.error; + floatingAutoResetTimer = setTimeout(() => setFloatingState(FloatState.IDLE), AUTO_RESET_DELAY); + break; + } +} + +function updateFloatingDetailPopup() { + const { detailResult, detailErrorRow, detailError, detailTime } = $floatingCache; + if (!detailResult) return; + + const elapsed = floatingResult.startTime + ? ((Date.now() - floatingResult.startTime) / 1000).toFixed(1) + : '-'; + + if (floatingState === FloatState.SUCCESS || floatingState === FloatState.PARTIAL) { + detailResult.textContent = `${floatingResult.success}/${floatingResult.total} 成功`; + detailResult.className = `nd-detail-value ${floatingState === FloatState.SUCCESS ? 'success' : 'warning'}`; + detailErrorRow.style.display = floatingState === FloatState.PARTIAL ? 'flex' : 'none'; + if (floatingState === FloatState.PARTIAL) { + detailError.textContent = `${floatingResult.total - floatingResult.success} 张失败`; + } + } else if (floatingState === FloatState.ERROR) { + detailResult.textContent = '生成失败'; + detailResult.className = 'nd-detail-value error'; + detailErrorRow.style.display = 'flex'; + detailError.textContent = floatingResult.error?.desc || '未知错误'; + } + + detailTime.textContent = `${elapsed}s`; +} + +function onFloatingPointerDown(e) { + if (e.button !== 0) return; + + floatingDragState = { + startX: e.clientX, + startY: e.clientY, + startLeft: floatingEl.getBoundingClientRect().left, + startTop: floatingEl.getBoundingClientRect().top, + pointerId: e.pointerId, + moved: false, + originalTarget: e.target + }; + + try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} + e.preventDefault(); +} + +function onFloatingPointerMove(e) { + if (!floatingDragState || floatingDragState.pointerId !== e.pointerId) return; + + const dx = e.clientX - floatingDragState.startX; + const dy = e.clientY - floatingDragState.startY; + + if (!floatingDragState.moved && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) { + floatingDragState.moved = true; + } + + if (floatingDragState.moved) { + const w = floatingEl.offsetWidth || 88; + const h = floatingEl.offsetHeight || 36; + floatingEl.style.left = `${Math.max(0, Math.min(floatingDragState.startLeft + dx, window.innerWidth - w))}px`; + floatingEl.style.top = `${Math.max(0, Math.min(floatingDragState.startTop + dy, window.innerHeight - h))}px`; + } + + e.preventDefault(); +} + +function onFloatingPointerUp(e) { + if (!floatingDragState || floatingDragState.pointerId !== e.pointerId) return; + + const { moved, originalTarget } = floatingDragState; + + try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {} + floatingDragState = null; + + if (moved) { + saveFloatingPosition(); + } else { + routeFloatingClick(originalTarget); + } +} + +function routeFloatingClick(target) { + if (target.closest('.nd-btn-draw')) { + handleFloatingDrawClick(); + } else if (target.closest('.nd-btn-menu')) { + floatingEl.classList.remove('show-detail'); + if (!floatingEl.classList.contains('expanded')) { + refreshFloatingPresetSelect(); + refreshFloatingSizeSelect(); + } + floatingEl.classList.toggle('expanded'); + } else if (target.closest('.nd-layer-active')) { + if ([FloatState.LLM, FloatState.GEN, FloatState.COOLDOWN].includes(floatingState)) { + handleFloatingAbort(); + } else if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(floatingState)) { + updateFloatingDetailPopup(); + floatingEl.classList.toggle('show-detail'); + } + } +} + +async function handleFloatingDrawClick() { + if (floatingState !== FloatState.IDLE) return; + + const messageId = findLastAIMessageId(); + if (messageId < 0) { + toastr?.warning?.('没有可配图的AI消息'); + return; + } + + try { + await generateAndInsertImages({ + messageId, + onStateChange: (state, data) => { + switch (state) { + case 'llm': setFloatingState(FloatState.LLM); break; + case 'gen': setFloatingState(FloatState.GEN, data); break; + case 'progress': setFloatingState(FloatState.GEN, data); break; + case 'cooldown': setFloatingState(FloatState.COOLDOWN, data); break; + case 'success': + if (data.aborted && data.success === 0) { + setFloatingState(FloatState.IDLE); + } else if (data.aborted || data.success < data.total) { + setFloatingState(FloatState.PARTIAL, data); + } else { + setFloatingState(FloatState.SUCCESS, data); + } + break; + } + } + }); + } catch (e) { + console.error('[NovelDraw]', e); + if (e.message === '已取消') { + setFloatingState(FloatState.IDLE); + } else { + setFloatingState(FloatState.ERROR, { error: classifyError(e) }); + } + } +} + +async function handleFloatingAbort() { + try { + const { abortGeneration } = await import('./novel-draw.js'); + if (abortGeneration()) { + setFloatingState(FloatState.IDLE); + toastr?.info?.('已中止'); + } + } catch (e) { + console.error('[NovelDraw] 中止失败:', e); + } +} + +function refreshFloatingPresetSelect() { + fillPresetSelect($floatingCache.presetSelect); +} + +function refreshFloatingSizeSelect() { + fillSizeSelect($floatingCache.sizeSelect); +} + +function cacheFloatingDOM() { + if (!floatingEl) return; + $floatingCache = { + capsule: floatingEl.querySelector('.nd-capsule'), + statusIcon: floatingEl.querySelector('.nd-status-icon'), + statusText: floatingEl.querySelector('.nd-status-text'), + detailResult: floatingEl.querySelector('.nd-result'), + detailErrorRow: floatingEl.querySelector('.nd-error-row'), + detailError: floatingEl.querySelector('.nd-error'), + detailTime: floatingEl.querySelector('.nd-time'), + presetSelect: floatingEl.querySelector('.nd-preset-select'), + sizeSelect: floatingEl.querySelector('.nd-size-select'), + autoToggle: floatingEl.querySelector('.nd-auto-toggle'), + }; +} + +function handleFloatingOutsideClick(e) { + if (floatingEl && !floatingEl.contains(e.target)) { + floatingEl.classList.remove('expanded', 'show-detail'); + } +} + +function createFloatingButton() { + if (floatingEl) return; + + const settings = getSettings(); + if (settings.showFloatingButton !== true) return; + + injectStyles(); + + const isAuto = settings.mode === 'auto'; + + floatingEl = document.createElement('div'); + floatingEl.className = `nd-float nd-floating-global${isAuto ? ' auto-on' : ''}`; + floatingEl.id = 'nd-floating-global'; + + const detail = createEl('div', 'nd-detail'); + const detailRowResult = createEl('div', 'nd-detail-row'); + detailRowResult.append( + createEl('span', 'nd-detail-icon', '📊'), + createEl('span', 'nd-detail-label', '结果'), + createEl('span', 'nd-detail-value nd-result', '-') + ); + const detailRowError = createEl('div', 'nd-detail-row nd-error-row'); + detailRowError.style.display = 'none'; + detailRowError.append( + createEl('span', 'nd-detail-icon', '💡'), + createEl('span', 'nd-detail-label', '原因'), + createEl('span', 'nd-detail-value error nd-error', '-') + ); + const detailRowTime = createEl('div', 'nd-detail-row'); + detailRowTime.append( + createEl('span', 'nd-detail-icon', '⏱'), + createEl('span', 'nd-detail-label', '耗时'), + createEl('span', 'nd-detail-value nd-time', '-') + ); + detail.append(detailRowResult, detailRowError, detailRowTime); + + const menu = createEl('div', 'nd-menu'); + const card = createEl('div', 'nd-card'); + const rowPreset = createEl('div', 'nd-row'); + rowPreset.appendChild(createEl('span', 'nd-label', '预设')); + const presetSelect = createEl('select', 'nd-select nd-preset-select'); + fillPresetSelect(presetSelect); + rowPreset.appendChild(presetSelect); + const innerSep = createEl('div', 'nd-inner-sep'); + const rowSize = createEl('div', 'nd-row'); + rowSize.appendChild(createEl('span', 'nd-label', '尺寸')); + const sizeSelect = createEl('select', 'nd-select size nd-size-select'); + fillSizeSelect(sizeSelect); + rowSize.appendChild(sizeSelect); + card.append(rowPreset, innerSep, rowSize); + + const controls = createEl('div', 'nd-controls'); + const autoToggle = createEl('div', `nd-auto${isAuto ? ' on' : ''} nd-auto-toggle`); + autoToggle.append( + createEl('span', 'nd-dot'), + createEl('span', 'nd-auto-text', '自动配图') + ); + const settingsBtn = createEl('button', 'nd-gear nd-settings-btn', '⚙'); + settingsBtn.title = '打开设置'; + controls.append(autoToggle, settingsBtn); + menu.append(card, controls); + + const capsule = createEl('div', 'nd-capsule'); + const inner = createEl('div', 'nd-inner'); + const layerIdle = createEl('div', 'nd-layer nd-layer-idle'); + const drawBtn = createEl('button', 'nd-btn-draw'); + drawBtn.title = '点击为最后一条AI消息生成配图'; + drawBtn.appendChild(createEl('span', '', '🎨')); + drawBtn.appendChild(createEl('span', 'nd-auto-dot')); + const sep = createEl('div', 'nd-sep'); + const menuBtn = createEl('button', 'nd-btn-menu'); + menuBtn.title = '展开菜单'; + menuBtn.appendChild(createEl('span', 'nd-arrow', '▲')); + layerIdle.append(drawBtn, sep, menuBtn); + const layerActive = createEl('div', 'nd-layer nd-layer-active'); + layerActive.append( + createEl('span', 'nd-status-icon', '⏳'), + createEl('span', 'nd-status-text', '分析') + ); + inner.append(layerIdle, layerActive); + capsule.appendChild(inner); + + floatingEl.append(detail, menu, capsule); + + document.body.appendChild(floatingEl); + cacheFloatingDOM(); + applyFloatingPosition(); + + const capsuleEl = $floatingCache.capsule; + if (capsuleEl) { + capsuleEl.addEventListener('pointerdown', onFloatingPointerDown, { passive: false }); + capsuleEl.addEventListener('pointermove', onFloatingPointerMove, { passive: false }); + capsuleEl.addEventListener('pointerup', onFloatingPointerUp, { passive: false }); + capsuleEl.addEventListener('pointercancel', onFloatingPointerUp, { passive: false }); + } + + $floatingCache.presetSelect?.addEventListener('change', (e) => { + const settings = getSettings(); + settings.selectedParamsPresetId = e.target.value; + saveSettings(settings); + updateAllPresetSelects(); + }); + + $floatingCache.sizeSelect?.addEventListener('change', (e) => { + const settings = getSettings(); + settings.overrideSize = e.target.value; + saveSettings(settings); + updateAllSizeSelects(); + }); + + $floatingCache.autoToggle?.addEventListener('click', () => { + const settings = getSettings(); + settings.mode = settings.mode === 'auto' ? 'manual' : 'auto'; + saveSettings(settings); + updateAutoModeUI(); + }); + + floatingEl.querySelector('.nd-settings-btn')?.addEventListener('click', () => { + floatingEl.classList.remove('expanded'); + openNovelDrawSettings(); + }); + + document.addEventListener('click', handleFloatingOutsideClick, { passive: true }); + window.addEventListener('resize', applyFloatingPosition); +} + +function destroyFloatingButton() { + clearFloatingCooldownTimer(); + + if (floatingAutoResetTimer) { + clearTimeout(floatingAutoResetTimer); + floatingAutoResetTimer = null; + } + + window.removeEventListener('resize', applyFloatingPosition); + document.removeEventListener('click', handleFloatingOutsideClick); + + floatingEl?.remove(); + floatingEl = null; + floatingDragState = null; + floatingState = FloatState.IDLE; + $floatingCache = {}; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 全局更新函数 // ═══════════════════════════════════════════════════════════════════════════ 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); + fillPresetSelect(data.$cache?.presetSelect); }); + fillPresetSelect($floatingCache.presetSelect); } 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); + fillSizeSelect(data.$cache?.sizeSelect); }); + fillSizeSelect($floatingCache.sizeSelect); } 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); + if (floatingEl) { + floatingEl.classList.toggle('auto-on', isAuto); + $floatingCache.autoToggle?.classList.toggle('on', isAuto); } } @@ -862,131 +1485,42 @@ export function refreshPresetSelectAll() { } // ═══════════════════════════════════════════════════════════════════════════ -// 面板挂载(懒加载) +// 按钮显示控制 // ═══════════════════════════════════════════════════════════════════════════ -function mountPanel(messageEl, messageId) { - if (panelMap.has(messageId)) { - const existing = panelMap.get(messageId); - if (existing.root?.isConnected) return existing; - existing._cleanup?.(); - panelMap.delete(messageId); +export function updateButtonVisibility(showFloor, showFloating) { + if (showFloating && !floatingEl) { + createFloatingButton(); + } else if (!showFloating && floatingEl) { + destroyFloatingButton(); } - - 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 (!showFloor) { + 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(); + floorObserver?.disconnect(); + floorObserver = null; } - - 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 initFloatingPanel() { + const settings = getSettings(); + + if (settings.showFloatingButton === true) { + createFloatingButton(); + } +} + export function destroyFloatingPanel() { panelMap.forEach((data, messageId) => { if (data.autoResetTimer) clearTimeout(data.autoResetTimer); @@ -996,18 +1530,21 @@ export function destroyFloatingPanel() { }); panelMap.clear(); pendingCallbacks.clear(); - - observer?.disconnect(); - observer = null; + + floorObserver?.disconnect(); + floorObserver = null; + + destroyFloatingButton(); } // ═══════════════════════════════════════════════════════════════════════════ // 导出 // ═══════════════════════════════════════════════════════════════════════════ -export { - FloatState, - updateProgress, - refreshPresetSelectAll as refreshPresetSelect, +export { + FloatState, + refreshPresetSelectAll as refreshPresetSelect, SIZE_OPTIONS, + createFloatingButton, + destroyFloatingButton, }; diff --git a/modules/novel-draw/novel-draw.html b/modules/novel-draw/novel-draw.html index ff9eee6..fc33863 100644 --- a/modules/novel-draw/novel-draw.html +++ b/modules/novel-draw/novel-draw.html @@ -65,6 +65,13 @@ body { display: flex; background: var(--bg-input); border: 1px solid var(--border); border-radius: 16px; padding: 2px; } +.header-toggles { display: flex; gap: 6px; margin-right: 8px; } +.header-toggle { + display: flex; align-items: center; gap: 4px; padding: 4px 8px; + background: var(--bg-input); border: 1px solid var(--border); border-radius: 12px; + font-size: 11px; color: var(--text-secondary); cursor: pointer; transition: all 0.15s; +} +.header-toggle input { accent-color: var(--accent); } .header-mode button { padding: 6px 14px; border: none; border-radius: 14px; background: transparent; color: var(--text-secondary); @@ -210,6 +217,7 @@ select.input { cursor: pointer; } border: 1px solid rgba(212, 165, 116, 0.2); border-radius: 8px; font-size: 12px; color: var(--text-secondary); line-height: 1.6; } +.tip-text { display: flex; flex-direction: column; gap: 4px; } .tip-box i { color: var(--accent); flex-shrink: 0; margin-top: 2px; } .gallery-char-section { margin-bottom: 16px; } .gallery-char-header { @@ -363,6 +371,16 @@ select.input { cursor: pointer; }
未启用
+
+ + +
@@ -410,7 +428,11 @@ select.input { cursor: pointer; }
-
聊天界面点击悬浮球 🎨 即可为最后一条AI消息生成配图。开启自动模式后,AI回复时会自动配图。
+
+
消息楼层按钮的 🎨 为对应消息生成配图。
+
悬浮按钮的 🎨 仅作用于最后一条AI消息。
+
开启自动模式后,AI回复时会自动配图。
+
@@ -829,7 +851,9 @@ let state = { paramsPresets: [], llmApi: { provider: 'st', url: '', key: '', model: '', modelCache: [] }, useStream: true, - characterTags: [] + characterTags: [], + showFloorButton: true, + showFloatingButton: false }; let gallerySummary = {}; @@ -1259,6 +1283,8 @@ function getCurrentLlmModel() { function applyStateToUI() { updateBadge(state.enabled); updateModeButtons(state.mode); + $('nd_show_floor').checked = state.showFloorButton !== false; + $('nd_show_floating').checked = state.showFloatingButton === true; $('nd_api_key').value = state.apiKey || ''; $('nd_timeout').value = Math.round((state.timeout > 0 ? state.timeout : DEFAULTS.timeout) / 1000); @@ -1488,6 +1514,22 @@ document.addEventListener('DOMContentLoaded', () => { updateModeButtons(state.mode); postToParent({ type: 'SAVE_MODE', mode: state.mode }); })); + + $('nd_show_floor').addEventListener('change', () => { + postToParent({ + type: 'SAVE_BUTTON_MODE', + showFloorButton: $('nd_show_floor').checked, + showFloatingButton: $('nd_show_floating').checked + }); + }); + + $('nd_show_floating').addEventListener('change', () => { + postToParent({ + type: 'SAVE_BUTTON_MODE', + showFloorButton: $('nd_show_floor').checked, + showFloatingButton: $('nd_show_floating').checked + }); + }); // ═══════════════════════════════════════════════════════════════════════ // 关闭按钮 diff --git a/modules/novel-draw/novel-draw.js b/modules/novel-draw/novel-draw.js index cc71b60..373b16c 100644 --- a/modules/novel-draw/novel-draw.js +++ b/modules/novel-draw/novel-draw.js @@ -87,6 +87,8 @@ const DEFAULT_SETTINGS = { useWorldInfo: false, characterTags: [], overrideSize: 'default', + showFloorButton: true, + showFloatingButton: false, }; // ═══════════════════════════════════════════════════════════════════════════ @@ -2097,6 +2099,8 @@ async function sendInitData() { useWorldInfo: settings.useWorldInfo, characterTags: settings.characterTags, overrideSize: settings.overrideSize, + showFloorButton: settings.showFloorButton !== false, + showFloatingButton: settings.showFloatingButton === true, }, cacheStats: stats, gallerySummary, @@ -2131,6 +2135,31 @@ async function handleFrameMessage(event) { break; } + case 'SAVE_BUTTON_MODE': { + const s = getSettings(); + if (typeof data.showFloorButton === 'boolean') s.showFloorButton = data.showFloorButton; + if (typeof data.showFloatingButton === 'boolean') s.showFloatingButton = data.showFloatingButton; + const ok = await saveSettingsAndToast(s, '已保存'); + if (ok) { + try { + const fp = await import('./floating-panel.js'); + fp.updateButtonVisibility?.(s.showFloorButton !== false, s.showFloatingButton === true); + } catch {} + if (s.showFloorButton !== false && typeof ensureNovelDrawPanelRef === 'function') { + const context = getContext(); + const chat = context.chat || []; + chat.forEach((message, messageId) => { + if (!message || message.is_user) return; + const messageEl = document.querySelector(`.mes[mesid="${messageId}"]`); + if (!messageEl) return; + ensureNovelDrawPanelRef?.(messageEl, messageId); + }); + } + sendInitData(); + } + break; + } + case 'SAVE_API_KEY': { const s = getSettings(); s.apiKey = typeof data.apiKey === 'string' ? data.apiKey : s.apiKey; @@ -2471,8 +2500,9 @@ export async function initNovelDraw() { // 动态导入 floating-panel(避免循环依赖) // ════════════════════════════════════════════════════════════════════ - const { ensureNovelDrawPanel: ensureNovelDrawPanelFn } = await import('./floating-panel.js'); + const { ensureNovelDrawPanel: ensureNovelDrawPanelFn, initFloatingPanel } = await import('./floating-panel.js'); ensureNovelDrawPanelRef = ensureNovelDrawPanelFn; + initFloatingPanel?.(); // 为现有消息创建画图面板 const renderExistingPanels = () => {