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 = `