From 0bd3cc57c5b0a1415999cfab5e2698b9467653ef Mon Sep 17 00:00:00 2001 From: henrryyes Date: Sun, 18 Jan 2026 01:48:30 +0800 Subject: [PATCH] Update local plugin changes --- modules/novel-draw/floating-panel.js | 1371 +++++++++++------------- modules/novel-draw/novel-draw.html | 2 +- modules/novel-draw/novel-draw.js | 188 +++- modules/story-summary/story-summary.js | 300 ++---- modules/tts/tts-overlay.html | 1321 +++++++++++++++++------ modules/tts/tts-panel.js | 437 ++++++-- modules/tts/tts.js | 62 +- 7 files changed, 2272 insertions(+), 1409 deletions(-) diff --git a/modules/novel-draw/floating-panel.js b/modules/novel-draw/floating-panel.js index 73412ea..7ab2015 100644 --- a/modules/novel-draw/floating-panel.js +++ b/modules/novel-draw/floating-panel.js @@ -1,19 +1,22 @@ // floating-panel.js +/** + * NovelDraw 画图按钮面板 + * 和 TTS 播放器一样,每条 AI 消息都有一个 + */ import { openNovelDrawSettings, generateAndInsertImages, getSettings, saveSettings, - findLastAIMessageId, classifyError, } from './novel-draw.js'; +import { registerToToolbar, removeFromToolbar } from '../../core/message-toolbar.js'; // ═══════════════════════════════════════════════════════════════════════════ // 常量 // ═══════════════════════════════════════════════════════════════════════════ -const FLOAT_POS_KEY = 'xb_novel_float_pos'; const AUTO_RESET_DELAY = 8000; const FloatState = { @@ -26,7 +29,6 @@ const FloatState = { ERROR: 'error', }; -// 尺寸预设 const SIZE_OPTIONS = [ { value: 'default', label: '跟随预设', width: null, height: null }, { value: '832x1216', label: '832 × 1216 竖图', width: 832, height: 1216 }, @@ -37,146 +39,71 @@ const SIZE_OPTIONS = [ ]; // ═══════════════════════════════════════════════════════════════════════════ -// 状态 +// 状态(每条消息独立) // ═══════════════════════════════════════════════════════════════════════════ -let floatEl = null; -let dragState = null; -let currentState = FloatState.IDLE; -let currentResult = { success: 0, total: 0, error: null, startTime: 0 }; -let autoResetTimer = null; -let cooldownRafId = null; -let cooldownEndTime = 0; - -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'), - sizeSelect: floatEl.querySelector('#nd-size-select'), - autoToggle: floatEl.querySelector('#nd-auto-toggle'), - }; -} +const panelMap = new Map(); // messageId -> panelData +const pendingCallbacks = new Map(); // messageId -> true +let observer = null; +let stylesInjected = false; // ═══════════════════════════════════════════════════════════════════════════ -// 样式 - 精致简约 +// 样式 - 菜单向下展开 // ═══════════════════════════════════════════════════════════════════════════ const STYLES = ` -/* ═══════════════════════════════════════════════════════════════════════════ - 设计令牌 (Design Tokens) - ═══════════════════════════════════════════════════════════════════════════ */ :root { - /* 胶囊尺寸 */ - --nd-w: 74px; --nd-h: 34px; - - /* 颜色系统 */ - --nd-bg-solid: rgba(24, 24, 28, 0.98); - --nd-bg-card: rgba(0, 0, 0, 0.35); - --nd-bg-hover: rgba(255, 255, 255, 0.06); + --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-subtle: rgba(255, 255, 255, 0.08); - --nd-border-default: rgba(255, 255, 255, 0.12); + --nd-border: rgba(255, 255, 255, 0.08); --nd-border-hover: rgba(255, 255, 255, 0.2); - - --nd-text-primary: rgba(255, 255, 255, 0.92); + --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.5); - - /* 语义色 */ - --nd-accent: #d4a574; + --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-sm: 0 2px 8px rgba(0, 0, 0, 0.25); - --nd-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35); - --nd-shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5); - - /* 圆角 */ + --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-radius-full: 9999px; - - /* 过渡 */ - --nd-transition-fast: 0.15s ease; - --nd-transition-normal: 0.25s ease; } -/* ═══════════════════════════════════════════════════════════════════════════ - 悬浮容器 - ═══════════════════════════════════════════════════════════════════════════ */ .nd-float { - position: fixed; - z-index: 10000; + position: relative; user-select: none; - will-change: transform; - contain: layout style; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } -/* ═══════════════════════════════════════════════════════════════════════════ - 胶囊主体 - ═══════════════════════════════════════════════════════════════════════════ */ .nd-capsule { - width: var(--nd-w); + width: 74px; height: var(--nd-h); - background: var(--nd-bg-solid); - border: 1px solid var(--nd-border-default); + background: var(--nd-bg); + border: 1px solid var(--nd-border); border-radius: 17px; - box-shadow: var(--nd-shadow-md); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); position: relative; overflow: hidden; - transition: border-color var(--nd-transition-normal), - box-shadow var(--nd-transition-normal), - background var(--nd-transition-normal); - touch-action: none; - cursor: grab; + transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); } -.nd-capsule:active { cursor: grabbing; } - .nd-float:hover .nd-capsule { + background: var(--nd-bg-hover); border-color: var(--nd-border-hover); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); } -/* 状态边框 */ -.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.06); -} -.nd-float.success .nd-capsule { - border-color: rgba(62, 207, 142, 0.6); - background: rgba(62, 207, 142, 0.06); -} -.nd-float.partial .nd-capsule { - border-color: rgba(240, 180, 41, 0.6); - background: rgba(240, 180, 41, 0.06); -} -.nd-float.error .nd-capsule { - border-color: rgba(248, 113, 113, 0.6); - background: rgba(248, 113, 113, 0.06); -} +.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%; @@ -207,7 +134,6 @@ const STYLES = ` pointer-events: none; } -/* 绘制按钮 */ .nd-btn-draw { flex: 1; height: 100%; @@ -219,13 +145,12 @@ const STYLES = ` justify-content: center; position: relative; color: var(--nd-text-primary); - transition: background var(--nd-transition-fast); + transition: background 0.15s; font-size: 16px; } -.nd-btn-draw:hover { background: var(--nd-bg-hover); } -.nd-btn-draw:active { background: var(--nd-bg-active); } +.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; @@ -239,21 +164,12 @@ const STYLES = ` transform: scale(0); transition: all 0.2s; } -.nd-float.auto-on .nd-auto-dot { - opacity: 1; - transform: scale(1); -} +.nd-float.auto-on .nd-auto-dot { opacity: 1; transform: scale(1); } -/* 分隔线 */ -.nd-sep { - width: 1px; - height: 14px; - background: var(--nd-border-subtle); -} +.nd-sep { width: 1px; height: 12px; background: var(--nd-border); } -/* 菜单按钮 */ .nd-btn-menu { - width: 28px; + width: 24px; height: 100%; border: none; background: transparent; @@ -261,21 +177,17 @@ const STYLES = ` display: flex; align-items: center; justify-content: center; - color: var(--nd-text-muted); + color: var(--nd-text-dim); font-size: 8px; - transition: all var(--nd-transition-fast); -} -.nd-btn-menu:hover { - background: var(--nd-bg-hover); - color: var(--nd-text-secondary); + 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%); @@ -303,69 +215,44 @@ const STYLES = ` .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; - will-change: transform; -} +.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-countdown { font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; } /* ═══════════════════════════════════════════════════════════════════════════ - 详情气泡 + 详情弹窗 - 向下展开 ═══════════════════════════════════════════════════════════════════════════ */ .nd-detail { position: absolute; - bottom: calc(100% + 10px); - left: 50%; - transform: translateX(-50%) translateY(4px); - background: var(--nd-bg-solid); - border: 1px solid var(--nd-border-default); - border-radius: var(--nd-radius-md); + 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: opacity var(--nd-transition-fast), transform var(--nd-transition-fast); - z-index: 10; -} - -.nd-detail::after { - content: ''; - position: absolute; - bottom: -6px; - left: 50%; - transform: translateX(-50%); - border: 6px solid transparent; - border-top-color: var(--nd-bg-solid); + 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: translateX(-50%) translateY(0); -} - -.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); + 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); } @@ -374,26 +261,26 @@ const STYLES = ` .nd-detail-value.error { color: var(--nd-error); } /* ═══════════════════════════════════════════════════════════════════════════ - 菜单面板 - 核心重构 + 菜单 - 向下展开 ═══════════════════════════════════════════════════════════════════════════ */ .nd-menu { position: absolute; - bottom: calc(100% + 10px); + top: calc(100% + 8px); right: 0; width: 190px; - background: var(--nd-bg-solid); - border: 1px solid var(--nd-border-default); - border-radius: var(--nd-radius-lg); + 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.98); - transform-origin: bottom right; - transition: opacity var(--nd-transition-fast), - transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), - visibility var(--nd-transition-fast); - z-index: 5; + 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 { @@ -402,35 +289,24 @@ const STYLES = ` transform: translateY(0) scale(1); } -/* ═══════════════════════════════════════════════════════════════════════════ - 参数卡片 - ═══════════════════════════════════════════════════════════════════════════ */ .nd-card { - background: var(--nd-bg-card); + background: rgba(255, 255, 255, 0.06); border: 1px solid var(--nd-border-subtle); border-radius: var(--nd-radius-md); overflow: hidden; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); } -.nd-row { - display: flex; - align-items: center; - padding: 2px 0; -} +.nd-row { display: flex; align-items: center; padding: 2px 0; } -/* 标签 - 提升可读性 */ .nd-label { width: 36px; padding-left: 10px; font-size: 10px; font-weight: 500; color: var(--nd-text-muted); - letter-spacing: 0.2px; flex-shrink: 0; } -/* 选择框 - 统一风格 */ .nd-select { flex: 1; min-width: 0; @@ -441,53 +317,20 @@ const STYLES = ` padding: 10px 8px; outline: none; cursor: pointer; - transition: color var(--nd-transition-fast); text-align: center; text-align-last: center; - margin: 0; - line-height: 1.2; } - .nd-select:hover { color: #fff; } -.nd-select:focus { color: #fff; } +.nd-select option { background: #1a1a1e; color: #eee; text-align: left; } +.nd-select.size { font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 11px; } -.nd-select option { - background: #1a1a1e; - color: #eee; - padding: 8px; - text-align: left; -} - -/* 尺寸选择框 - 等宽字体,白色文字 */ -.nd-select.size { - font-family: "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; - font-size: 11px; - letter-spacing: -0.2px; -} - -/* 内部分隔线 */ .nd-inner-sep { height: 1px; - background: linear-gradient( - 90deg, - transparent 8px, - var(--nd-border-subtle) 8px, - var(--nd-border-subtle) calc(100% - 8px), - transparent calc(100% - 8px) - ); + background: linear-gradient(90deg, transparent 8px, var(--nd-border-subtle) 8px, var(--nd-border-subtle) calc(100% - 8px), transparent calc(100% - 8px)); } -/* ═══════════════════════════════════════════════════════════════════════════ - 控制栏 - ═══════════════════════════════════════════════════════════════════════════ */ -.nd-controls { - display: flex; - align-items: center; - gap: 8px; - margin-top: 10px; -} +.nd-controls { display: flex; align-items: center; gap: 8px; margin-top: 10px; } -/* 自动开关 */ .nd-auto { flex: 1; display: flex; @@ -498,18 +341,10 @@ const STYLES = ` border: 1px solid var(--nd-border-subtle); border-radius: var(--nd-radius-sm); cursor: pointer; - transition: all var(--nd-transition-fast); -} - -.nd-auto:hover { - background: var(--nd-bg-hover); - border-color: var(--nd-border-default); -} - -.nd-auto.on { - background: rgba(62, 207, 142, 0.08); - border-color: rgba(62, 207, 142, 0.3); + 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; @@ -517,29 +352,13 @@ const STYLES = ` border-radius: 50%; background: rgba(255, 255, 255, 0.2); transition: all 0.2s; - flex-shrink: 0; } +.nd-auto.on .nd-dot { background: var(--nd-success); box-shadow: 0 0 8px rgba(62, 207, 142, 0.5); } -.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-auto-text { - font-size: 12px; - color: var(--nd-text-muted); - transition: color var(--nd-transition-fast); -} - -.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; @@ -552,23 +371,15 @@ const STYLES = ` align-items: center; justify-content: center; font-size: 14px; - transition: all var(--nd-transition-fast); - flex-shrink: 0; -} - -.nd-gear:hover { - background: var(--nd-bg-hover); - border-color: var(--nd-border-default); - color: var(--nd-text-secondary); -} - -.nd-gear:active { - background: var(--nd-bg-active); + transition: all 0.15s; } +.nd-gear:hover { background: rgba(255, 255, 255, 0.08); color: var(--nd-text-secondary); } `; function injectStyles() { - if (document.getElementById('nd-float-styles')) return; + if (stylesInjected) return; + stylesInjected = true; + const el = document.createElement('style'); el.id = 'nd-float-styles'; el.textContent = STYLES; @@ -576,323 +387,25 @@ function injectStyles() { } // ═══════════════════════════════════════════════════════════════════════════ -// 位置管理 +// 面板数据结构 // ═══════════════════════════════════════════════════════════════════════════ -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 (cooldownRafId) { - cancelAnimationFrame(cooldownRafId); - cooldownRafId = null; - } - cooldownEndTime = 0; -} - -function startCooldownTimer(duration) { - clearCooldownTimer(); - cooldownEndTime = Date.now() + duration; - - function tick() { - if (!cooldownEndTime) return; - updateCooldownDisplay(); - const remaining = cooldownEndTime - Date.now(); - if (remaining <= -100) { - clearCooldownTimer(); - return; - } - cooldownRafId = requestAnimationFrame(tick); - } - - cooldownRafId = requestAnimationFrame(tick); -} - -function updateCooldownDisplay() { - const { statusText } = $cache; - if (!statusText) return; - const remaining = Math.max(0, cooldownEndTime - Date.now()); - const seconds = (remaining / 1000).toFixed(1); - statusText.textContent = `${seconds}s`; - statusText.className = 'nd-countdown'; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 状态管理 -// ═══════════════════════════════════════════════════════════════════════════ - -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; - - 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 +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, }; - - 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(); - refreshSizeSelect(); - } - floatEl.classList.toggle('expanded'); - } else if (target.closest('#nd-layer-active')) { - - if ([FloatState.LLM, FloatState.GEN, FloatState.COOLDOWN].includes(currentState)) { - - handleAbort(); - } else 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); break; - case 'cooldown': setState(FloatState.COOLDOWN, data); break; - case 'success': - // ▼ 修改:中止时也显示结果 - if (data.aborted && data.success === 0) { - setState(FloatState.IDLE); - } else if (data.aborted || data.success < data.total) { - setState(FloatState.PARTIAL, data); - } else { - setState(FloatState.SUCCESS, data); - } - break; - } - } - }); - } catch (e) { - console.error('[NovelDraw]', e); - // ▼ 修改:中止不显示错误 - if (e.message === '已取消') { - setState(FloatState.IDLE); - } else { - setState(FloatState.ERROR, { error: classifyError(e) }); - } - } -} - -async function handleAbort() { - try { - const { abortGeneration } = await import('./novel-draw.js'); - if (abortGeneration()) { - setState(FloatState.IDLE); - toastr?.info?.('已中止'); - } - } catch (e) { - console.error('[NovelDraw] 中止失败:', e); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 预设与尺寸管理 +// 面板创建 - 箭头改为向下 ▼ // ═══════════════════════════════════════════════════════════════════════════ function buildPresetOptions() { @@ -912,192 +425,576 @@ function buildSizeOptions() { ).join(''); } -function refreshPresetSelect() { - if (!$cache.presetSelect) return; - // Template-only UI markup. - // eslint-disable-next-line no-unsanitized/property - $cache.presetSelect.innerHTML = buildPresetOptions(); +function fillSelectOptions(select, options, currentValue) { + if (!select) return; + select.textContent = ''; + options.forEach((opt) => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === currentValue) option.selected = true; + select.appendChild(option); + }); } -function refreshSizeSelect() { - if (!$cache.sizeSelect) return; - // Template-only UI markup. - // eslint-disable-next-line no-unsanitized/property - $cache.sizeSelect.innerHTML = buildSizeOptions(); -} - -function handlePresetChange(e) { - const presetId = e.target.value; - if (!presetId) return; - const settings = getSettings(); - settings.selectedParamsPresetId = presetId; - saveSettings(settings); -} - -function handleSizeChange(e) { - const value = e.target.value; - const settings = getSettings(); - settings.overrideSize = value; - saveSettings(settings); -} - -export function updateAutoModeUI() { - if (!floatEl) return; - const isAuto = getSettings().mode === 'auto'; - floatEl.classList.toggle('auto-on', isAuto); - $cache.autoToggle?.classList.toggle('on', isAuto); -} - -function handleAutoToggle() { - const settings = getSettings(); - settings.mode = settings.mode === 'auto' ? 'manual' : 'auto'; - saveSettings(settings); - updateAutoModeUI(); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 创建与销毁 -// ═══════════════════════════════════════════════════════════════════════════ - -export function createFloatingPanel() { - if (floatEl) return; - - injectStyles(); - +function createPanelElement(messageId) { const settings = getSettings(); const isAuto = settings.mode === 'auto'; - floatEl = document.createElement('div'); - floatEl.className = `nd-float${isAuto ? ' auto-on' : ''}`; - floatEl.id = 'nd-floating-panel'; + const el = document.createElement('div'); + el.className = `nd-float${isAuto ? ' auto-on' : ''}`; + el.dataset.messageId = messageId; - // Template-only UI markup. + // Template-only UI markup built locally. // eslint-disable-next-line no-unsanitized/property - floatEl.innerHTML = ` - -
-
- 📊 - 结果 - - -
- -
- - 耗时 - - -
-
- - -
- -
-
- 预设 - -
-
-
- 尺寸 - -
-
- - -
-
- - 自动配图 -
- -
-
- - + el.innerHTML = `
-
-
-
- - 分析 +
+ + 分析
+ +
+
+ 📊 + 结果 + - +
+ +
+ + 耗时 + - +
+
+ +
+
+
+ 预设 + +
+
+
+ 尺寸 + +
+
+
+
+ + 自动配图 +
+ +
+
`; - document.body.appendChild(floatEl); - cacheDOM(); - applyPosition(); - bindEvents(); - - window.addEventListener('resize', applyPosition); + return el; } -function bindEvents() { - const capsule = $cache.capsule; - if (!capsule) return; +function cacheDOM(panelData) { + const el = panelData.root; + if (!el) 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 }); + 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; - $cache.presetSelect?.addEventListener('change', handlePresetChange); - $cache.sizeSelect?.addEventListener('change', handleSizeChange); - $cache.autoToggle?.addEventListener('click', handleAutoToggle); + const el = panelData.root; + panelData.state = state; - floatEl.querySelector('#nd-settings-btn')?.addEventListener('click', () => { - floatEl.classList.remove('expanded'); + // 清除旧定时器 + 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(); }); - document.addEventListener('click', handleOutsideClick, { passive: true }); + 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 handleOutsideClick(e) { - if (floatEl && !floatEl.contains(e.target)) { - floatEl.classList.remove('expanded', 'show-detail'); +// ═══════════════════════════════════════════════════════════════════════════ +// 全局更新 +// ═══════════════════════════════════════════════════════════════════════════ + +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() { - clearCooldownTimer(); + 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(); - if (autoResetTimer) { - clearTimeout(autoResetTimer); - autoResetTimer = null; - } - - window.removeEventListener('resize', applyPosition); - document.removeEventListener('click', handleOutsideClick); - - floatEl?.remove(); - floatEl = null; - dragState = null; - currentState = FloatState.IDLE; - $cache = {}; + observer?.disconnect(); + observer = null; } // ═══════════════════════════════════════════════════════════════════════════ // 导出 // ═══════════════════════════════════════════════════════════════════════════ -export { FloatState, setState, updateProgress, refreshPresetSelect, SIZE_OPTIONS }; +export { + FloatState, + updateProgress, + refreshPresetSelectAll as refreshPresetSelect, + SIZE_OPTIONS, +}; diff --git a/modules/novel-draw/novel-draw.html b/modules/novel-draw/novel-draw.html index 443f2d7..ff9eee6 100644 --- a/modules/novel-draw/novel-draw.html +++ b/modules/novel-draw/novel-draw.html @@ -662,7 +662,7 @@ select.input { cursor: pointer; }
diff --git a/modules/novel-draw/novel-draw.js b/modules/novel-draw/novel-draw.js index 8e43133..cc71b60 100644 --- a/modules/novel-draw/novel-draw.js +++ b/modules/novel-draw/novel-draw.js @@ -43,7 +43,7 @@ const CONFIG_VERSION = 4; const MAX_SEED = 0xFFFFFFFF; const API_TEST_TIMEOUT = 15000; const PLACEHOLDER_REGEX = /\[image:([a-z0-9\-_]+)\]/gi; -const INITIAL_RENDER_MESSAGE_LIMIT = 10; +const INITIAL_RENDER_MESSAGE_LIMIT = 1; const events = createModuleEvents(MODULE_KEY); @@ -103,6 +103,7 @@ let settingsCache = null; let settingsLoaded = false; let generationAbortController = null; let messageObserver = null; +let ensureNovelDrawPanelRef = null; // ═══════════════════════════════════════════════════════════════════════════ // 样式 @@ -177,6 +178,13 @@ function ensureStyles() { .xb-nd-edit-input:focus{border-color:rgba(212,165,116,0.5);outline:none} .xb-nd-edit-input.scene{border-color:rgba(212,165,116,0.3)} .xb-nd-edit-input.char{border-color:rgba(147,197,253,0.3)} +.xb-nd-live-btn{position:absolute;bottom:10px;right:10px;z-index:5;padding:4px 8px;background:rgba(0,0,0,0.75);border:none;border-radius:12px;color:rgba(255,255,255,0.7);font-size:10px;font-weight:700;letter-spacing:0.5px;cursor:pointer;opacity:0.7;transition:all 0.2s;user-select:none} +.xb-nd-live-btn:hover{opacity:1;background:rgba(0,0,0,0.85)} +.xb-nd-live-btn.active{background:rgba(62,207,142,0.9);color:#fff;opacity:1;box-shadow:0 0 10px rgba(62,207,142,0.5)} +.xb-nd-live-btn.loading{pointer-events:none;opacity:0.5} +.xb-nd-img.mode-live .xb-nd-img-wrap>img{opacity:0!important;pointer-events:none} +.xb-nd-live-canvas{border-radius:10px;overflow:hidden} +.xb-nd-live-canvas canvas{display:block;border-radius:10px} `; document.head.appendChild(style); } @@ -770,6 +778,7 @@ function buildImageHtml({ slotId, imgId, url, tags, positive, messageId, state = ${displayVersion} / ${historyCount}
`; + const liveBtn = ``; const menuBusy = isBusy ? ' busy' : ''; const menuHtml = `
@@ -787,6 +796,7 @@ ${indicator}
${navPill} + ${liveBtn}
${menuHtml}