2026-01-18 17:24:19 +08:00
|
|
|
|
// floating-panel.js
|
2026-01-18 01:48:30 +08:00
|
|
|
|
/**
|
2026-01-18 17:24:19 +08:00
|
|
|
|
* NovelDraw 画图按钮面板 - 支持楼层按钮和悬浮按钮双模式
|
2026-01-18 01:48:30 +08:00
|
|
|
|
*/
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
import {
|
2026-01-17 16:34:39 +08:00
|
|
|
|
openNovelDrawSettings,
|
|
|
|
|
|
generateAndInsertImages,
|
|
|
|
|
|
getSettings,
|
|
|
|
|
|
saveSettings,
|
2026-01-18 17:24:19 +08:00
|
|
|
|
findLastAIMessageId,
|
2026-01-17 16:34:39 +08:00
|
|
|
|
classifyError,
|
2026-01-18 18:23:54 +08:00
|
|
|
|
isGenerating,
|
2026-01-17 16:34:39 +08:00
|
|
|
|
} from './novel-draw.js';
|
2026-01-18 11:44:14 +08:00
|
|
|
|
import { registerToToolbar, removeFromToolbar } from '../../widgets/message-toolbar.js';
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 常量
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
const FLOAT_POS_KEY = 'xb_novel_float_pos';
|
2026-01-17 16:34:39 +08:00
|
|
|
|
const AUTO_RESET_DELAY = 8000;
|
|
|
|
|
|
|
|
|
|
|
|
const FloatState = {
|
|
|
|
|
|
IDLE: 'idle',
|
|
|
|
|
|
LLM: 'llm',
|
|
|
|
|
|
GEN: 'gen',
|
|
|
|
|
|
COOLDOWN: 'cooldown',
|
|
|
|
|
|
SUCCESS: 'success',
|
|
|
|
|
|
PARTIAL: 'partial',
|
|
|
|
|
|
ERROR: 'error',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const SIZE_OPTIONS = [
|
|
|
|
|
|
{ value: 'default', label: '跟随预设', width: null, height: null },
|
|
|
|
|
|
{ value: '832x1216', label: '832 × 1216 竖图', width: 832, height: 1216 },
|
|
|
|
|
|
{ value: '1216x832', label: '1216 × 832 横图', width: 1216, height: 832 },
|
|
|
|
|
|
{ value: '1024x1024', label: '1024 × 1024 方图', width: 1024, height: 1024 },
|
|
|
|
|
|
{ value: '768x1280', label: '768 x 1280 大竖', width: 768, height: 1280 },
|
|
|
|
|
|
{ value: '1280x768', label: '1280 x 768 大横', width: 1280, height: 768 },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-18 17:24:19 +08:00
|
|
|
|
// 状态
|
2026-01-17 16:34:39 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
// 楼层按钮状态
|
|
|
|
|
|
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 = {};
|
|
|
|
|
|
|
|
|
|
|
|
// 通用状态
|
2026-01-18 01:48:30 +08:00
|
|
|
|
let stylesInjected = false;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-18 17:24:19 +08:00
|
|
|
|
// 样式 - 统一样式(楼层+悬浮共用)
|
2026-01-17 16:34:39 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
const STYLES = `
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--nd-h: 34px;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
--nd-bg: rgba(0, 0, 0, 0.55);
|
2026-01-18 17:24:19 +08:00
|
|
|
|
--nd-bg-solid: rgba(24, 24, 28, 0.98);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
--nd-bg-hover: rgba(0, 0, 0, 0.7);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--nd-bg-active: rgba(255, 255, 255, 0.1);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
--nd-border: rgba(255, 255, 255, 0.08);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--nd-border-hover: rgba(255, 255, 255, 0.2);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
--nd-border-subtle: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
--nd-text-primary: rgba(255, 255, 255, 0.85);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--nd-text-secondary: rgba(255, 255, 255, 0.65);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
--nd-text-muted: rgba(255, 255, 255, 0.45);
|
|
|
|
|
|
--nd-text-dim: rgba(255, 255, 255, 0.25);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--nd-success: #3ecf8e;
|
|
|
|
|
|
--nd-warning: #f0b429;
|
|
|
|
|
|
--nd-error: #f87171;
|
|
|
|
|
|
--nd-info: #60a5fa;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
--nd-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--nd-radius-sm: 6px;
|
|
|
|
|
|
--nd-radius-md: 10px;
|
|
|
|
|
|
--nd-radius-lg: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
楼层按钮样式
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
.nd-float {
|
|
|
|
|
|
position: relative;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
user-select: none;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-capsule {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
width: 74px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
height: var(--nd-h);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
background: var(--nd-bg);
|
|
|
|
|
|
border: 1px solid var(--nd-border);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
border-radius: 17px;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
backdrop-filter: blur(16px);
|
|
|
|
|
|
-webkit-backdrop-filter: blur(16px);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
position: relative;
|
|
|
|
|
|
overflow: hidden;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-float:hover .nd-capsule {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
background: var(--nd-bg-hover);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
border-color: var(--nd-border-hover);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.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); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
.nd-inner {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
grid-template-areas: "s";
|
|
|
|
|
|
pointer-events: none;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
.nd-layer {
|
|
|
|
|
|
grid-area: s;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
transition: opacity 0.2s, transform 0.2s;
|
|
|
|
|
|
pointer-events: auto;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-layer-idle { opacity: 1; transform: translateY(0); }
|
|
|
|
|
|
|
|
|
|
|
|
.nd-float.working .nd-layer-idle,
|
|
|
|
|
|
.nd-float.cooldown .nd-layer-idle,
|
|
|
|
|
|
.nd-float.success .nd-layer-idle,
|
|
|
|
|
|
.nd-float.partial .nd-layer-idle,
|
|
|
|
|
|
.nd-float.error .nd-layer-idle {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateY(-100%);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-btn-draw {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
color: var(--nd-text-primary);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
transition: background 0.15s;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-btn-draw:hover { background: rgba(255, 255, 255, 0.12); }
|
|
|
|
|
|
.nd-btn-draw:active { transform: scale(0.92); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.nd-auto-dot {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 7px;
|
|
|
|
|
|
right: 6px;
|
|
|
|
|
|
width: 6px;
|
|
|
|
|
|
height: 6px;
|
|
|
|
|
|
background: var(--nd-success);
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
box-shadow: 0 0 6px rgba(62, 207, 142, 0.6);
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: scale(0);
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-float.auto-on .nd-auto-dot { opacity: 1; transform: scale(1); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-sep { width: 1px; height: 12px; background: var(--nd-border); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.nd-btn-menu {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
width: 24px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
height: 100%;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
color: var(--nd-text-dim);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
font-size: 8px;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
opacity: 0.6;
|
|
|
|
|
|
transition: opacity 0.25s, transform 0.25s;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-float:hover .nd-btn-menu { opacity: 1; }
|
|
|
|
|
|
.nd-btn-menu:hover { background: rgba(255, 255, 255, 0.12); color: var(--nd-text-muted); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.nd-arrow { transition: transform 0.2s; }
|
|
|
|
|
|
.nd-float.expanded .nd-arrow { transform: rotate(180deg); }
|
|
|
|
|
|
|
|
|
|
|
|
.nd-layer-active {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateY(100%);
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-float.working .nd-layer-active,
|
|
|
|
|
|
.nd-float.cooldown .nd-layer-active,
|
|
|
|
|
|
.nd-float.success .nd-layer-active,
|
|
|
|
|
|
.nd-float.partial .nd-layer-active,
|
|
|
|
|
|
.nd-float.error .nd-layer-active {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: translateY(0);
|
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-float.cooldown .nd-layer-active { color: var(--nd-info); }
|
|
|
|
|
|
.nd-float.success .nd-layer-active { color: var(--nd-success); }
|
|
|
|
|
|
.nd-float.partial .nd-layer-active { color: var(--nd-warning); }
|
|
|
|
|
|
.nd-float.error .nd-layer-active { color: var(--nd-error); }
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-spin { display: inline-block; animation: nd-spin 1.5s linear infinite; }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
@keyframes nd-spin { to { transform: rotate(360deg); } }
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-countdown { font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
/* 详情弹窗 - 向下展开(楼层按钮用) */
|
2026-01-17 16:34:39 +08:00
|
|
|
|
.nd-detail {
|
|
|
|
|
|
position: absolute;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
top: calc(100% + 8px);
|
|
|
|
|
|
right: 0;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
background: rgba(18, 18, 22, 0.98);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
border: 1px solid var(--nd-border);
|
|
|
|
|
|
border-radius: 12px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--nd-text-secondary);
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
box-shadow: var(--nd-shadow-lg);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
backdrop-filter: blur(20px);
|
|
|
|
|
|
-webkit-backdrop-filter: blur(20px);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
opacity: 0;
|
|
|
|
|
|
visibility: hidden;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-float.show-detail .nd-detail {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
visibility: visible;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
transform: translateY(0) scale(1);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.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); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
.nd-detail-icon { opacity: 0.6; font-size: 13px; }
|
|
|
|
|
|
.nd-detail-label { color: var(--nd-text-muted); }
|
|
|
|
|
|
.nd-detail-value { margin-left: auto; font-weight: 600; color: var(--nd-text-primary); }
|
|
|
|
|
|
.nd-detail-value.success { color: var(--nd-success); }
|
|
|
|
|
|
.nd-detail-value.warning { color: var(--nd-warning); }
|
|
|
|
|
|
.nd-detail-value.error { color: var(--nd-error); }
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
/* 菜单 - 向下展开(楼层按钮用) */
|
2026-01-17 16:34:39 +08:00
|
|
|
|
.nd-menu {
|
|
|
|
|
|
position: absolute;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
top: calc(100% + 8px);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
right: 0;
|
|
|
|
|
|
width: 190px;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
background: rgba(18, 18, 22, 0.96);
|
|
|
|
|
|
border: 1px solid var(--nd-border);
|
|
|
|
|
|
border-radius: 12px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
padding: 10px;
|
|
|
|
|
|
box-shadow: var(--nd-shadow-lg);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
backdrop-filter: blur(20px);
|
|
|
|
|
|
-webkit-backdrop-filter: blur(20px);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
opacity: 0;
|
|
|
|
|
|
visibility: hidden;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-float.expanded .nd-menu {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
visibility: visible;
|
|
|
|
|
|
transform: translateY(0) scale(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-card {
|
2026-01-18 02:20:37 +08:00
|
|
|
|
background: transparent;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
|
overflow: visible;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
.nd-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
padding: 6px 2px;
|
|
|
|
|
|
min-height: 36px;
|
|
|
|
|
|
}
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.nd-label {
|
2026-01-18 02:20:37 +08:00
|
|
|
|
font-size: 11px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
color: var(--nd-text-muted);
|
2026-01-18 02:20:37 +08:00
|
|
|
|
width: 32px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
flex-shrink: 0;
|
2026-01-18 02:20:37 +08:00
|
|
|
|
padding: 0;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nd-select {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
2026-01-18 02:20:37 +08:00
|
|
|
|
background: rgba(255, 255, 255, 0.06);
|
|
|
|
|
|
border: 1px solid var(--nd-border-subtle);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
color: var(--nd-text-primary);
|
2026-01-18 02:20:37 +08:00
|
|
|
|
font-size: 11px;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
min-height: 32px;
|
2026-01-18 02:20:37 +08:00
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
box-sizing: border-box;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
outline: none;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
text-align-last: center;
|
2026-01-18 02:20:37 +08:00
|
|
|
|
transition: border-color 0.2s;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
vertical-align: middle;
|
2026-01-18 02:20:37 +08:00
|
|
|
|
-webkit-appearance: none;
|
|
|
|
|
|
-moz-appearance: none;
|
|
|
|
|
|
appearance: none;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 02:20:37 +08:00
|
|
|
|
.nd-select:hover { border-color: rgba(255, 255, 255, 0.2); }
|
|
|
|
|
|
.nd-select:focus { border-color: rgba(255, 255, 255, 0.3); }
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-select option { background: #1a1a1e; color: #eee; text-align: left; }
|
2026-01-18 17:30:56 +08:00
|
|
|
|
.nd-select.size { font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 11px; }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 02:20:37 +08:00
|
|
|
|
.nd-inner-sep { display: none; }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-controls { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.nd-auto {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
padding: 9px 12px;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.03);
|
|
|
|
|
|
border: 1px solid var(--nd-border-subtle);
|
|
|
|
|
|
border-radius: var(--nd-radius-sm);
|
|
|
|
|
|
cursor: pointer;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
transition: all 0.15s;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.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); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.nd-dot {
|
|
|
|
|
|
width: 7px;
|
|
|
|
|
|
height: 7px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.2);
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-auto.on .nd-dot { background: var(--nd-success); box-shadow: 0 0 8px rgba(62, 207, 142, 0.5); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.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); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.nd-gear {
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 36px;
|
|
|
|
|
|
border: 1px solid var(--nd-border-subtle);
|
|
|
|
|
|
border-radius: var(--nd-radius-sm);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.03);
|
|
|
|
|
|
color: var(--nd-text-muted);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 14px;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
transition: all 0.15s;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.nd-gear:hover { background: rgba(255, 255, 255, 0.08); color: var(--nd-text-secondary); }
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
|
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
悬浮按钮样式(固定定位,可拖拽)
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
.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); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
function injectStyles() {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (stylesInjected) return;
|
|
|
|
|
|
stylesInjected = true;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
const el = document.createElement('style');
|
|
|
|
|
|
el.id = 'nd-float-styles';
|
|
|
|
|
|
el.textContent = STYLES;
|
|
|
|
|
|
document.head.appendChild(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-18 17:24:19 +08:00
|
|
|
|
// 通用工具函数
|
2026-01-17 16:34:39 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function createEl(tag, className, text) {
|
|
|
|
|
|
const el = document.createElement(tag);
|
|
|
|
|
|
if (className) el.className = className;
|
|
|
|
|
|
if (text !== undefined) el.textContent = text;
|
|
|
|
|
|
return el;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function fillPresetSelect(selectEl) {
|
|
|
|
|
|
if (!selectEl) return;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const settings = getSettings();
|
|
|
|
|
|
const presets = settings.paramsPresets || [];
|
|
|
|
|
|
const currentId = settings.selectedParamsPresetId;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function fillSizeSelect(selectEl) {
|
|
|
|
|
|
if (!selectEl) return;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const settings = getSettings();
|
|
|
|
|
|
const current = settings.overrideSize || 'default';
|
2026-01-18 17:24:19 +08:00
|
|
|
|
selectEl.replaceChildren();
|
|
|
|
|
|
SIZE_OPTIONS.forEach(opt => {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const option = document.createElement('option');
|
2026-01-18 17:24:19 +08:00
|
|
|
|
option.value = opt.value;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
option.textContent = opt.label;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
if (opt.value === current) option.selected = true;
|
|
|
|
|
|
selectEl.appendChild(option);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
});
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// ▼▼▼ 楼层按钮逻辑 ▼▼▼
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
function createFloorPanelData(messageId) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
messageId,
|
|
|
|
|
|
root: null,
|
|
|
|
|
|
state: FloatState.IDLE,
|
|
|
|
|
|
result: { success: 0, total: 0, error: null, startTime: 0 },
|
|
|
|
|
|
autoResetTimer: null,
|
|
|
|
|
|
cooldownRafId: null,
|
|
|
|
|
|
cooldownEndTime: 0,
|
|
|
|
|
|
$cache: {},
|
|
|
|
|
|
_cleanup: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createFloorPanelElement(messageId) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const settings = getSettings();
|
|
|
|
|
|
const isAuto = settings.mode === 'auto';
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function cacheFloorDOM(panelData) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const el = panelData.root;
|
|
|
|
|
|
if (!el) return;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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'),
|
|
|
|
|
|
};
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function setFloorState(messageId, state, data = {}) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const panelData = panelMap.get(messageId);
|
|
|
|
|
|
if (!panelData?.root) return;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const el = panelData.root;
|
|
|
|
|
|
panelData.state = state;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (panelData.autoResetTimer) {
|
|
|
|
|
|
clearTimeout(panelData.autoResetTimer);
|
|
|
|
|
|
panelData.autoResetTimer = null;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (state !== FloatState.COOLDOWN && panelData.cooldownRafId) {
|
|
|
|
|
|
cancelAnimationFrame(panelData.cooldownRafId);
|
|
|
|
|
|
panelData.cooldownRafId = null;
|
|
|
|
|
|
panelData.cooldownEndTime = 0;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
el.classList.remove('working', 'cooldown', 'success', 'partial', 'error', 'show-detail');
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const { statusIcon, statusText } = panelData.$cache;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
switch (state) {
|
|
|
|
|
|
case FloatState.IDLE:
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelData.result = { success: 0, total: 0, error: null, startTime: 0 };
|
2026-01-17 16:34:39 +08:00
|
|
|
|
break;
|
|
|
|
|
|
case FloatState.LLM:
|
2026-01-18 01:48:30 +08:00
|
|
|
|
el.classList.add('working');
|
|
|
|
|
|
panelData.result.startTime = Date.now();
|
|
|
|
|
|
if (statusIcon) { statusIcon.textContent = '⏳'; statusIcon.className = 'nd-status-icon nd-spin'; }
|
|
|
|
|
|
if (statusText) statusText.textContent = '分析';
|
2026-01-17 16:34:39 +08:00
|
|
|
|
break;
|
|
|
|
|
|
case FloatState.GEN:
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
break;
|
|
|
|
|
|
case FloatState.COOLDOWN:
|
2026-01-18 01:48:30 +08:00
|
|
|
|
el.classList.add('cooldown');
|
|
|
|
|
|
if (statusIcon) { statusIcon.textContent = '⏳'; statusIcon.className = 'nd-status-icon nd-spin'; }
|
2026-01-18 17:24:19 +08:00
|
|
|
|
startFloorCooldownTimer(panelData, data.duration);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
break;
|
|
|
|
|
|
case FloatState.SUCCESS:
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
panelData.autoResetTimer = setTimeout(() => setFloorState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
break;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
case FloatState.PARTIAL:
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
panelData.autoResetTimer = setTimeout(() => setFloorState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
break;
|
|
|
|
|
|
case FloatState.ERROR:
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
panelData.autoResetTimer = setTimeout(() => setFloorState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function startFloorCooldownTimer(panelData, duration) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelData.cooldownEndTime = Date.now() + duration;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
panelData.cooldownRafId = requestAnimationFrame(tick);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function updateFloorDetailPopup(messageId) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const panelData = panelMap.get(messageId);
|
|
|
|
|
|
if (!panelData?.root) return;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const { result: resultEl, errorRow, error: errorEl, time: timeEl } = panelData.$cache;
|
|
|
|
|
|
const { result, state } = panelData;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
|
|
|
|
|
const elapsed = result.startTime
|
|
|
|
|
|
? ((Date.now() - result.startTime) / 1000).toFixed(1)
|
2026-01-18 01:48:30 +08:00
|
|
|
|
: '-';
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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'}`;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (errorRow) errorRow.style.display = state === FloatState.PARTIAL ? 'flex' : 'none';
|
|
|
|
|
|
if (errorEl && state === FloatState.PARTIAL) {
|
|
|
|
|
|
errorEl.textContent = `${result.total - result.success} 张失败`;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
} 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 || '未知错误';
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (timeEl) timeEl.textContent = `${elapsed}s`;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
async function handleFloorDrawClick(messageId) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const panelData = panelMap.get(messageId);
|
|
|
|
|
|
if (!panelData || panelData.state !== FloatState.IDLE) return;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 18:23:54 +08:00
|
|
|
|
if (isGenerating()) {
|
|
|
|
|
|
toastr?.info?.('已有任务进行中,请等待完成');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await generateAndInsertImages({
|
|
|
|
|
|
messageId,
|
|
|
|
|
|
onStateChange: (state, data) => {
|
|
|
|
|
|
switch (state) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
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;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
case 'success':
|
|
|
|
|
|
if (data.aborted && data.success === 0) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
setFloorState(messageId, FloatState.IDLE);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
} else if (data.aborted || data.success < data.total) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
setFloorState(messageId, FloatState.PARTIAL, data);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
} else {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
setFloorState(messageId, FloatState.SUCCESS, data);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('[NovelDraw]', e);
|
2026-01-18 18:23:54 +08:00
|
|
|
|
if (e.message === '已取消' || e.message?.includes('已有任务进行中')) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
setFloorState(messageId, FloatState.IDLE);
|
2026-01-18 18:23:54 +08:00
|
|
|
|
if (e.message?.includes('已有任务进行中')) toastr?.info?.(e.message);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
} else {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
setFloorState(messageId, FloatState.ERROR, { error: classifyError(e) });
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
async function handleFloorAbort(messageId) {
|
2026-01-17 16:34:39 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const { abortGeneration } = await import('./novel-draw.js');
|
|
|
|
|
|
if (abortGeneration()) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
setFloorState(messageId, FloatState.IDLE);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
toastr?.info?.('已中止');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('[NovelDraw] 中止失败:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function bindFloorPanelEvents(panelData) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const { messageId, root: el } = panelData;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
el.querySelector('.nd-btn-draw')?.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation();
|
2026-01-18 17:24:19 +08:00
|
|
|
|
handleFloorDrawClick(messageId);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
});
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
el.querySelector('.nd-btn-menu')?.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
el.classList.remove('show-detail');
|
|
|
|
|
|
if (!el.classList.contains('expanded')) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
refreshFloorPresetSelect(messageId);
|
|
|
|
|
|
refreshFloorSizeSelect(messageId);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
el.classList.toggle('expanded');
|
|
|
|
|
|
});
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
el.querySelector('.nd-layer-active')?.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
const state = panelData.state;
|
|
|
|
|
|
if ([FloatState.LLM, FloatState.GEN, FloatState.COOLDOWN].includes(state)) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
handleFloorAbort(messageId);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
} else if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(state)) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
updateFloorDetailPopup(messageId);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
el.classList.toggle('show-detail');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelData.$cache.presetSelect?.addEventListener('change', (e) => {
|
|
|
|
|
|
const settings = getSettings();
|
|
|
|
|
|
settings.selectedParamsPresetId = e.target.value;
|
|
|
|
|
|
saveSettings(settings);
|
|
|
|
|
|
updateAllPresetSelects();
|
|
|
|
|
|
});
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelData.$cache.sizeSelect?.addEventListener('change', (e) => {
|
|
|
|
|
|
const settings = getSettings();
|
|
|
|
|
|
settings.overrideSize = e.target.value;
|
|
|
|
|
|
saveSettings(settings);
|
|
|
|
|
|
updateAllSizeSelects();
|
|
|
|
|
|
});
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelData.$cache.autoToggle?.addEventListener('click', () => {
|
|
|
|
|
|
const settings = getSettings();
|
|
|
|
|
|
settings.mode = settings.mode === 'auto' ? 'manual' : 'auto';
|
|
|
|
|
|
saveSettings(settings);
|
|
|
|
|
|
updateAutoModeUI();
|
|
|
|
|
|
});
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
el.querySelector('.nd-settings-btn')?.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
el.classList.remove('expanded');
|
|
|
|
|
|
openNovelDrawSettings();
|
|
|
|
|
|
});
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const closeMenu = (e) => {
|
|
|
|
|
|
if (!el.contains(e.target)) {
|
|
|
|
|
|
el.classList.remove('expanded', 'show-detail');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener('click', closeMenu, { passive: true });
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelData._cleanup = () => {
|
|
|
|
|
|
document.removeEventListener('click', closeMenu);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function refreshFloorPresetSelect(messageId) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const data = panelMap.get(messageId);
|
|
|
|
|
|
const select = data?.$cache?.presetSelect;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
fillPresetSelect(select);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function refreshFloorSizeSelect(messageId) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const data = panelMap.get(messageId);
|
|
|
|
|
|
const select = data?.$cache?.sizeSelect;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
fillSizeSelect(select);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function mountFloorPanel(messageEl, messageId) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (panelMap.has(messageId)) {
|
|
|
|
|
|
const existing = panelMap.get(messageId);
|
|
|
|
|
|
if (existing.root?.isConnected) return existing;
|
|
|
|
|
|
existing._cleanup?.();
|
|
|
|
|
|
panelMap.delete(messageId);
|
|
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
injectStyles();
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
|
|
|
|
|
const panelData = createFloorPanelData(messageId);
|
|
|
|
|
|
const panel = createFloorPanelElement(messageId);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelData.root = panel;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const success = registerToToolbar(messageId, panel, {
|
|
|
|
|
|
position: 'right',
|
|
|
|
|
|
id: `novel-draw-${messageId}`
|
|
|
|
|
|
});
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
|
|
|
|
|
if (!success) return null;
|
|
|
|
|
|
|
|
|
|
|
|
cacheFloorDOM(panelData);
|
|
|
|
|
|
bindFloorPanelEvents(panelData);
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelMap.set(messageId, panelData);
|
|
|
|
|
|
return panelData;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
function setupFloorObserver() {
|
|
|
|
|
|
if (floorObserver) return;
|
|
|
|
|
|
|
|
|
|
|
|
floorObserver = new IntersectionObserver((entries) => {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const toMount = [];
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
|
if (!entry.isIntersecting) continue;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const el = entry.target;
|
|
|
|
|
|
const mid = Number(el.getAttribute('mesid'));
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (pendingCallbacks.has(mid)) {
|
|
|
|
|
|
toMount.push({ el, mid });
|
|
|
|
|
|
pendingCallbacks.delete(mid);
|
2026-01-18 17:24:19 +08:00
|
|
|
|
floorObserver.unobserve(el);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (toMount.length > 0) {
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
for (const { el, mid } of toMount) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
mountFloorPanel(el, mid);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { rootMargin: '300px' });
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
export function ensureNovelDrawPanel(messageEl, messageId, options = {}) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
const settings = getSettings();
|
|
|
|
|
|
if (settings.showFloorButton === false) return null;
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const { force = false } = options;
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
injectStyles();
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (panelMap.has(messageId)) {
|
|
|
|
|
|
const existing = panelMap.get(messageId);
|
|
|
|
|
|
if (existing.root?.isConnected) return existing;
|
|
|
|
|
|
existing._cleanup?.();
|
|
|
|
|
|
panelMap.delete(messageId);
|
|
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (force) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
return mountFloorPanel(messageEl, messageId);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const rect = messageEl.getBoundingClientRect();
|
|
|
|
|
|
if (rect.top < window.innerHeight + 500 && rect.bottom > -500) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
return mountFloorPanel(messageEl, messageId);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
|
|
|
|
|
setupFloorObserver();
|
2026-01-18 01:48:30 +08:00
|
|
|
|
pendingCallbacks.set(messageId, true);
|
2026-01-18 17:24:19 +08:00
|
|
|
|
floorObserver.observe(messageEl);
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
return null;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
export function setStateForMessage(messageId, state, data = {}) {
|
|
|
|
|
|
let panelData = panelMap.get(messageId);
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
if (!panelData?.root?.isConnected) {
|
|
|
|
|
|
const messageEl = document.querySelector(`.mes[mesid="${messageId}"]`);
|
|
|
|
|
|
if (messageEl) {
|
|
|
|
|
|
panelData = ensureNovelDrawPanel(messageEl, messageId, { force: true });
|
|
|
|
|
|
}
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
|
|
|
|
|
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消息');
|
2026-01-18 01:48:30 +08:00
|
|
|
|
return;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
2026-01-18 18:23:54 +08:00
|
|
|
|
if (isGenerating()) {
|
|
|
|
|
|
toastr?.info?.('已有任务进行中,请等待完成');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
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);
|
2026-01-18 18:23:54 +08:00
|
|
|
|
if (e.message === '已取消' || e.message?.includes('已有任务进行中')) {
|
2026-01-18 17:24:19 +08:00
|
|
|
|
setFloatingState(FloatState.IDLE);
|
2026-01-18 18:23:54 +08:00
|
|
|
|
if (e.message?.includes('已有任务进行中')) toastr?.info?.(e.message);
|
2026-01-18 17:24:19 +08:00
|
|
|
|
} 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() {
|
|
|
|
|
|
panelMap.forEach((data) => {
|
|
|
|
|
|
fillPresetSelect(data.$cache?.presetSelect);
|
|
|
|
|
|
});
|
|
|
|
|
|
fillPresetSelect($floatingCache.presetSelect);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateAllSizeSelects() {
|
|
|
|
|
|
panelMap.forEach((data) => {
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (floatingEl) {
|
|
|
|
|
|
floatingEl.classList.toggle('auto-on', isAuto);
|
|
|
|
|
|
$floatingCache.autoToggle?.classList.toggle('on', isAuto);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function refreshPresetSelectAll() {
|
|
|
|
|
|
updateAllPresetSelects();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 按钮显示控制
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
export function updateButtonVisibility(showFloor, showFloating) {
|
|
|
|
|
|
if (showFloating && !floatingEl) {
|
|
|
|
|
|
createFloatingButton();
|
|
|
|
|
|
} else if (!showFloating && floatingEl) {
|
|
|
|
|
|
destroyFloatingButton();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-18 17:24:19 +08:00
|
|
|
|
// 初始化与清理
|
2026-01-18 01:48:30 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
export function initFloatingPanel() {
|
|
|
|
|
|
const settings = getSettings();
|
|
|
|
|
|
|
|
|
|
|
|
if (settings.showFloatingButton === true) {
|
|
|
|
|
|
createFloatingButton();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
export function destroyFloatingPanel() {
|
|
|
|
|
|
panelMap.forEach((data, messageId) => {
|
|
|
|
|
|
if (data.autoResetTimer) clearTimeout(data.autoResetTimer);
|
|
|
|
|
|
if (data.cooldownRafId) cancelAnimationFrame(data.cooldownRafId);
|
|
|
|
|
|
data._cleanup?.();
|
|
|
|
|
|
if (data.root) removeFromToolbar(messageId, data.root);
|
|
|
|
|
|
});
|
|
|
|
|
|
panelMap.clear();
|
|
|
|
|
|
pendingCallbacks.clear();
|
2026-01-18 17:24:19 +08:00
|
|
|
|
|
|
|
|
|
|
floorObserver?.disconnect();
|
|
|
|
|
|
floorObserver = null;
|
|
|
|
|
|
|
|
|
|
|
|
destroyFloatingButton();
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 导出
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 17:24:19 +08:00
|
|
|
|
export {
|
|
|
|
|
|
FloatState,
|
|
|
|
|
|
refreshPresetSelectAll as refreshPresetSelect,
|
2026-01-18 01:48:30 +08:00
|
|
|
|
SIZE_OPTIONS,
|
2026-01-18 17:24:19 +08:00
|
|
|
|
createFloatingButton,
|
|
|
|
|
|
destroyFloatingButton,
|
2026-01-18 18:23:54 +08:00
|
|
|
|
setFloatingState,
|
2026-01-18 01:48:30 +08:00
|
|
|
|
};
|