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