// ==UserScript== // @name DeepSeek 功能增强工具箱 // @namespace https://github.com/Chuc-Jie/deepseektool // @version 5.7.0 // @description 为 DeepSeek 对话页提供统一控制面板:代码块折叠与外观、代码块导出为图片(8 项样式可调)、表格列宽自适应与 PNG/CSV/Markdown 导出(列宽方式 / 表格宽度 / 溢出策略 / 表头对齐可调)、对话导出为 Markdown / JSON(勾选 + 9 套模板)、AI 思考过程自动折叠、对话文件夹分组、宽屏模式(三档宽度)与 Ctrl+Enter 发送。设置即时生效、无需刷新。 // @tag 工具 // @tag 优化 // @tag DeepSeek // @author 友野YouyEr // @icon https://fe-static.deepseek.com/chat/favicon.svg // @match https://chat.deepseek.com/* // @match https://www.deepseek.com/* // @match https://deepseek.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @require https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js // @run-at document-end // @license MIT // ==/UserScript== (function() { 'use strict'; // ==================== 存储键与全局变量 ==================== const STORAGE_FOLD_THRESHOLD = 'deepseek_fold_threshold'; const STORAGE_PREVIEW_LINES = 'deepseek_fold_preview_lines'; const STORAGE_TABLE_BUTTONS_ENABLED = 'deepseek_table_buttons_enabled'; const STORAGE_TABLE_BUTTONS_ALWAYS = 'deepseek_table_buttons_always'; // 导出按钮恒定显示(默认关) const STORAGE_AUTO_COLLAPSE_THINKING = 'deepseek_auto_collapse_thinking'; const STORAGE_SIMULATE_CLICK_THINKING = 'deepseek_simulate_click_thinking'; const STORAGE_TABLE_THEME_MODE = 'deepseek_table_theme_mode'; const STORAGE_TABLE_HEAD_ALIGN = 'deepseek_table_head_align'; // 表头对齐:auto(跟随内容)/ center const STORAGE_TABLE_WIDTH_ALGO = 'deepseek_table_width_algo'; // 列宽算法:smart / equal const STORAGE_TABLE_FILL = 'deepseek_table_fill'; // 铺满整行 / 内容紧凑 const STORAGE_TABLE_OVERFLOW = 'deepseek_table_overflow'; // 列过多时:scroll / squeeze const LEGACY_TABLE_WIDTH_MODE = 'deepseek_table_width_mode'; // 旧键:只做一次性迁移,之后不再读取 const STORAGE_WIDE_SCREEN = 'deepseek_wide_screen'; const STORAGE_WIDE_LEVEL = 'deepseek_wide_screen_level'; // 宽屏档位:standard / wide / wider(默认 standard) // 宽屏三档:以「官方默认 840px」为基线,standard 即历史上的 1000px(+160), // 另两档把增量翻倍 / 三倍。数值是**绝对上限**,可用宽度不足时会被 100% 封顶(窄窗口下三档趋同)。 const WIDE_LEVELS = { standard: { px: 1000, label: '标准(+160px)' }, wide: { px: 1160, label: '较宽(+320px)' }, wider: { px: 1320, label: '最宽(+480px)' }, }; const WIDE_LEVEL_DEFAULT = 'standard'; const STORAGE_FOLDER_MANAGER = 'deepseek_folder_manager_enabled'; // 对话文件夹管理总开关(默认关) const STORAGE_FOLDER_DATA = 'deepseek_folder_manager_data_v2'; // 文件夹+归属数据(新键,不与旧独立脚本互相干扰) const STORAGE_CTRL_ENTER = 'deepseek_ctrl_enter'; // 发送快捷键:Ctrl+Enter(默认关) const STORAGE_PIN_COLLAPSED = 'deepseek_pin_group_collapsed'; // 原生「置顶」分组折叠态(默认展开) const STORAGE_PIN_COLLAPSIBLE = 'deepseek_pin_group_collapsible'; // 原生「置顶」分组折叠能力开关(默认关,opt-in) const STORAGE_CODE_BG = 'deepseek_code_bg_enhance'; // 代码块背景加深(默认开) const STORAGE_CODE_BG_LEVEL = 'deepseek_code_bg_level'; // 加深强度档位:light / medium / strong const STORAGE_TABLE_EXPORT_ROUNDED = 'deepseek_table_export_rounded'; // PNG 导出表格四角圆角(默认开) const STORAGE_MD_EXPORT_ENABLED = 'deepseek_md_export_enabled'; // 对话导出总开关(Markdown / JSON,默认开) const STORAGE_CODE_EXPORT_ENABLED = 'deepseek_code_export_enabled'; // 代码块「导出为图片」按钮总开关(默认开) const STORAGE_CODE_IMAGE_PREFS = 'deepseek_code_image_prefs'; // 代码块导图样式偏好(JSON,记住上次选择) // 说明:v4.12 起「含思考过程 / 仅导出 AI 回答」不再作为设置项,改为在导出弹窗里每次询问。 // 因此**不得**再把旧键 deepseek_md_export_reasoning / _answer_only 读作默认值 —— // 那会形成一个「用户在设置里看不见、也关不掉」的隐形开关(曾导致「全选后只导出回答」的事故)。 const STORAGE_MD_EXPORT_PREFS = 'deepseek_md_export_prefs'; // 弹窗「记住我的选择」落库(JSON) const STORAGE_MD_EXPORT_APPEND_DATE = 'deepseek_md_export_append_date'; // 文件名附加日期(默认开,避免重名覆盖) const LEGACY_MD_KEYS = ['deepseek_md_export_reasoning', 'deepseek_md_export_answer_only']; let foldThreshold = GM_getValue(STORAGE_FOLD_THRESHOLD, 20); let previewLines = GM_getValue(STORAGE_PREVIEW_LINES, 0); let enablePreviewLines = previewLines > 0; let tableButtonsEnabled = GM_getValue(STORAGE_TABLE_BUTTONS_ENABLED, true); let tableButtonsAlways = GM_getValue(STORAGE_TABLE_BUTTONS_ALWAYS, false); // 导出按钮恒定常显(默认关) let autoCollapseThinking = GM_getValue(STORAGE_AUTO_COLLAPSE_THINKING, true); let simulateClickThinking = GM_getValue(STORAGE_SIMULATE_CLICK_THINKING, true); let tableThemeMode = GM_getValue(STORAGE_TABLE_THEME_MODE, 'auto'); let tableHeadAlign = GM_getValue(STORAGE_TABLE_HEAD_ALIGN, 'auto'); // 默认跟随 Markdown 的对齐语义 // 列宽算法:旧键 deepseek_table_width_mode 的 'equal' 保留为均分选择; // 'auto'(字符数权重)/ 'equal-minwidth'(与均分等价)/ 未设置 一律迁移为「智能」。 // 迁移后立即写新键,此后代码不再读旧键——否则会造出面板里看不见也关不掉的隐形状态。 let tableWidthAlgo = GM_getValue(STORAGE_TABLE_WIDTH_ALGO, null); if (tableWidthAlgo !== 'smart' && tableWidthAlgo !== 'equal') { tableWidthAlgo = GM_getValue(LEGACY_TABLE_WIDTH_MODE, null) === 'equal' ? 'equal' : 'smart'; GM_setValue(STORAGE_TABLE_WIDTH_ALGO, tableWidthAlgo); } let tableFill = GM_getValue(STORAGE_TABLE_FILL, 'fill'); // 默认铺满整行 let tableOverflow = GM_getValue(STORAGE_TABLE_OVERFLOW, 'scroll'); // 默认横向滚动 let wideScreen = GM_getValue(STORAGE_WIDE_SCREEN, false); let wideScreenLevel = GM_getValue(STORAGE_WIDE_LEVEL, WIDE_LEVEL_DEFAULT); if (!WIDE_LEVELS[wideScreenLevel]) wideScreenLevel = WIDE_LEVEL_DEFAULT; // 脏值兜底 let folderManagerEnabled = GM_getValue(STORAGE_FOLDER_MANAGER, false); // 对话文件夹管理(默认关,opt-in) let ctrlEnterEnabled = GM_getValue(STORAGE_CTRL_ENTER, false); // Ctrl+Enter 发送(默认关,opt-in) let pinGroupCollapsible = GM_getValue(STORAGE_PIN_COLLAPSIBLE, false); // 置顶分组可折叠(默认关,opt-in) let codeBgEnhance = GM_getValue(STORAGE_CODE_BG, true); // 代码块背景加深(默认开) let codeBgLevel = GM_getValue(STORAGE_CODE_BG_LEVEL, 'light'); // 加深强度(默认轻档) let tableExportRounded = GM_getValue(STORAGE_TABLE_EXPORT_ROUNDED, true); // PNG 导出圆角(默认开) let mdExportEnabled = GM_getValue(STORAGE_MD_EXPORT_ENABLED, true); // 对话导出(Markdown / JSON,默认开) let mdExportAppendDate = GM_getValue(STORAGE_MD_EXPORT_APPEND_DATE, true); // 文件名附加日期(默认开) let codeExportEnabled = GM_getValue(STORAGE_CODE_EXPORT_ENABLED, true); // 代码块导图按钮(默认开) let codeImagePrefs = GM_getValue(STORAGE_CODE_IMAGE_PREFS, null); // 导图样式偏好(对象或 JSON 字符串) const btnTextFold = '折叠'; const btnTextUnfold = '展开'; // ==================== SVG 图标 (代码块折叠) ==================== const ICON_CHEVRON_DOWN = ``; const ICON_CHEVRON_UP = ``; // 「导出为图片」按钮图标(内联 SVG,fill=currentColor 以随按钮文字色;与折叠按钮同为 20px) const ICON_EXPORT_IMAGE = ``; // ==================== 通用 Toast ==================== function showToast(message, duration = 2000) { const existingToast = document.getElementById('ds-fold-toast'); if (existingToast) existingToast.remove(); const toast = document.createElement('div'); toast.id = 'ds-fold-toast'; toast.textContent = message; toast.style.cssText = ` position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); backdrop-filter: blur(8px); color: white; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-family: system-ui, -apple-system, sans-serif; z-index: 10001; opacity: 0; transition: opacity 0.2s; pointer-events: none; white-space: nowrap; box-shadow: 0 2px 8px rgba(0,0,0,0.2); `; document.body.appendChild(toast); setTimeout(() => toast.style.opacity = '1', 10); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 200); }, duration); } // ==================== 发送快捷键:Ctrl+Enter 发送 / Enter 换行(可开关,默认关) ==================== // 来自社区 PR(wha4up)并在 v4.7.0 复核。DeepSeek 原生为“Enter 发送、Shift+Enter 换行”; // 开启后:纯 Enter → stopPropagation 让官方改用“仅换行”;Ctrl/Cmd+Enter → 以不带修饰的 Enter 事件 // 再次触发,让官方按 Enter(发送/换行)处理,从而在打字区按【Ctrl+Enter】等效发送。 let _supressNextEnter = false; document.addEventListener('keydown', (e) => { if (!ctrlEnterEnabled) return; if (e.target && e.target.tagName !== 'TEXTAREA') return; if (e.key !== 'Enter') return; if (_supressNextEnter) { _supressNextEnter = false; return; } if (e.ctrlKey || e.metaKey) { // Ctrl/Cmd+Enter → 视同 Enter(发送/换行取决于表单当前语义),不携带 ctrl/meta e.preventDefault(); e.stopPropagation(); _supressNextEnter = true; e.target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true, ctrlKey: false, metaKey: false, shiftKey: false, })); } else if (!e.shiftKey) { // 开启模式下把“纯 Enter”改视为换行,不再发送 e.stopPropagation(); } // Shift+Enter 保持原生语义(换行)不做拦截 }, true); // ==================== 统一控制面板 ==================== function openControlPanel() { const existingOverlay = document.getElementById('ds-control-panel-overlay'); if (existingOverlay) existingOverlay.remove(); // 面板每次重建 → 上一轮登记的依赖项作废(否则会持有已移除的节点) _panelDepWatchers.length = 0; const overlay = document.createElement('div'); overlay.id = 'ds-control-panel-overlay'; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(8, 13, 20, .55); -webkit-backdrop-filter: saturate(160%) blur(4px); backdrop-filter: saturate(160%) blur(4px); animation: dsPanelOverlayIn 180ms cubic-bezier(.2, 0, 0, 1) forwards; z-index: 10001; display: flex; align-items: center; justify-content: center; `; const panel = document.createElement('div'); panel.className = 'ds-panel'; /* ===== 左侧导航 ===== */ const sidebar = document.createElement('aside'); sidebar.className = 'ds-p-sidebar'; const NAV_ITEMS = [ { key: 'fold', icon: 'code-tags', label: '代码块折叠' }, { key: 'codebg', icon: 'format-color-fill', label: '代码块外观' }, { key: 'codeexport', icon: 'image-multiple-outline', label: '代码块导出' }, { key: 'table', icon: 'table-large', label: '表格优化导出' }, { key: 'mdexport', icon: 'language-markdown', label: '对话导出' }, { key: 'thinking', icon: 'brain', label: 'AI 思考折叠' }, { key: 'wide', icon: 'monitor', label: '宽屏模式' }, { key: 'chat', icon: 'send', label: '聊天发送' }, { key: 'folder', icon: 'folder-outline', label: '对话文件夹' }, ]; const NAV_EXTRA = [ { key: 'help', icon: 'help-circle-outline', label: '帮助中心' }, { key: 'about', icon: 'information-outline', label: '关于' }, ]; // iconify 图标:左侧导航 / logo(深蓝底固定亮色);右侧标题需随主题取色 const ICON_HOST = 'https://api.iconify.design/mdi:'; const isPanelDark = () => document.body.classList.contains('dark'); const mdiNavUrl = (name) => ICON_HOST + name + '.svg?color=%23e6eaf2'; const mdiHeadUrl = (name) => ICON_HOST + name + '.svg?color=' + (isPanelDark() ? '%23e4e4e8' : '%231e293b'); function makeIconImg(name, cls, url) { const im = document.createElement('img'); im.className = cls; im.src = url(name); im.alt = ''; im.draggable = false; return im; } // 外链卡片(帮助/关于页):整卡可点跳外链,target=_blank function createLinkCard(title, desc, href, iconName) { const a = document.createElement('a'); a.className = 'ds-link-card'; a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.appendChild(makeIconImg(iconName, 'ds-link-card-ic', mdiHeadUrl)); const tx = document.createElement('span'); tx.className = 'ds-link-card-tx'; const t = document.createElement('span'); t.className = 'ds-link-card-title'; t.textContent = title; tx.appendChild(t); if (desc) { const d = document.createElement('span'); d.className = 'ds-link-card-desc'; d.textContent = desc; tx.appendChild(d); } a.appendChild(tx); return a; } function createLinkCardGrid(cards) { const g = document.createElement('div'); g.className = 'ds-link-grid'; cards.forEach(c => g.appendChild(c)); return g; } const logo = document.createElement('div'); logo.className = 'ds-p-logo'; const logoIc = document.createElement('span'); logoIc.className = 'ds-p-logo-ic'; logoIc.appendChild(makeIconImg('cog', 'ds-p-logo-ic-img', mdiNavUrl)); const logoTx = document.createElement('div'); logoTx.className = 'ds-p-logo-tx'; logoTx.innerHTML = '
是等宽字体且有 padding:2px 4px —— 用正文字体量会低估(实测「单元格内需写成 |」
// 测出 106.6px,真实需要 118px,超过可用的 114.4px 就折行)。
// 另需计入 letter-spacing(表头 0.02em)与 1.5px 亚像素余量。
function measureColumnNaturals(table, cols) {
const ctx = getMeasureCtx();
const cells = table.querySelectorAll('th,td');
if (!ctx || !cells.length || !cols) return null;
// 按「标签 + 类名」缓存计算样式:不同 tag/class 组合通常 < 10 种
const styleCache = new Map();
const styleOf = el => {
const key = el.tagName + '|' + (el.className || '');
if (styleCache.has(key)) return styleCache.get(key);
const cs = getComputedStyle(el);
const v = {
font: cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily,
letterSpacing: parseFloat(cs.letterSpacing) || 0,
padX: (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0),
borderX: (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0),
};
styleCache.set(key, v);
return v;
};
let activeFont = '';
// 递归累加:文本节点用「父元素」字体;元素节点加上自身的 padding/border(如 code)
const measureNode = node => {
let w = 0;
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
const st = styleOf(node);
if (st.font !== activeFont) { ctx.font = st.font; activeFont = st.font; }
const t = child.textContent;
if (t) w += ctx.measureText(t).width + st.letterSpacing * Math.max(0, t.length - 1);
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (child.tagName === 'BR') continue; // 换行取最长行,不影响单行宽度
const st = styleOf(child);
w += measureNode(child) + st.padX + st.borderX;
}
}
return w;
};
const natural = new Array(cols).fill(0);
for (const cell of cells) {
const idx = cell.cellIndex; // 真实列索引(Markdown 表格无 colspan,可直接用)
if (idx < 0 || idx >= cols) continue;
if (!cell.textContent) continue;
const w = measureNode(cell) + 1.5;
if (w > natural[idx]) natural[idx] = w;
}
return natural;
}
// 列宽分配(单位:内容宽度,不含 TABLE_CELL_FIXED):
// 预算够:每列直接拿到「单行放下」所需的 natural 宽度,富余按需求摊掉(铺满模式)。
// 预算不够:用 **max-min fair(二分水位)** 均衡分配 —— 让「最窄的列尽可能宽」,
// 而不是「短列吃饱、最长列停在初始值」。
// 实测:3 列表 nat≈[275, 600, 439]、预算 810 时,旧的「短列优先补满」会给出
// [275, 96, 439],问题列每行折 7 行;改为二分水位后约 [270, 270, 270],
// 每列折 1~2 行,整表高度明显下降(行高由最高的单元格决定,饿死一列的代价是整表变高)。
// 放不下时:scroll 交给横向滚动(保可读),squeeze 等比缩进容器(保持无滚动条)。
function planColumnWidths(natural, cols, budget, opts) {
const fill = !!(opts && opts.fill);
const overflowMode = (opts && opts.overflow) || 'scroll';
if (!cols) return { widths: [], overflow: false, squeezed: false };
// 溢出判定基准:受舒适宽约束的需求(单列超过 96 的部分不参与判定,
// 否则表里只要有一列超长就会误触发滚动)
const demand = natural.reduce((s, x) => s + Math.min(x, TABLE_COL_COMFORT), 0);
if (demand > budget) {
// 溢出容差:只超出一点点时(实测 10 列表 580 vs 572,差 8px)若立刻切成滚动,
// 观感会从「紧凑 890px」突跳到「1605px + 滚动条」,而正文换行数并不减少。
// 故超出 8% 以上才认为「压缩会明显不可读」,否则在预算内均衡分配。
if (overflowMode === 'scroll' && demand > budget * TABLE_OVERFLOW_TOLERANCE) {
const wide = natural.map(x => Math.min(TABLE_COL_MAX, Math.max(x, TABLE_COL_COMFORT)));
return { widths: wide, overflow: true, squeezed: false };
}
if (overflowMode === 'squeeze') {
const k = budget / demand;
return {
widths: natural.map(x => Math.max(1, Math.min(x, TABLE_COL_COMFORT) * k)),
overflow: false,
squeezed: true,
};
}
}
// max-min fair:二分水位 λ,使 Σ min(natural_i, λ) 逼近 budget。
// 短列(natural < λ)拿满自己的内容宽度;长列共享 λ —— 各列的「压缩程度」一致,
// 折行数自然接近,整表高度最小。
let lo = 0;
let hi = natural.reduce((a, b) => Math.max(a, b), 1);
for (let it = 0; it < 40; it++) {
const mid = (lo + hi) / 2;
let sum = 0;
for (let i = 0; i < cols; i++) sum += Math.min(natural[i], mid);
if (sum < budget) lo = mid; else hi = mid;
}
const widths = natural.map(x => Math.max(1, Math.min(x, lo)));
let rest = budget - widths.reduce((a, b) => a + b, 0);
if (rest > 0) {
// 剩余很少(二分残差)或需求本就小于预算:先把过窄的列抬到可读下限,
// 再按需求权重摊掉(铺满模式)。
const order = Array.from({ length: cols }, (_, i) => i).sort((a, b) => natural[a] - natural[b]);
for (const i of order) {
const gap = TABLE_COL_MIN - widths[i];
if (gap <= 0) continue;
const add = Math.min(gap, rest);
widths[i] += add;
rest -= add;
if (rest <= 0) break;
}
}
if (rest > 0 && fill) {
// 剩余空间**均分**,而不是按 natural 权重摊。
// 走到这里说明每列都已满足「单行放下」,富余只是观感;
// 按 nat 加权会把内容差异放大——长表头(nat=211、正文仅 42)那列会吃掉大部分富余,
// 实测 3 列表变成 [116, 603, 91],中间一列空得离谱。均分后为 [218, 387, 204]。
const per = rest / cols;
for (let i = 0; i < cols; i++) widths[i] += per;
rest = 0;
}
return { widths, overflow: false, squeezed: false, rest };
}
// 表格可用宽度:必须取「不会被内容撑开」的容器。
// 实测(2026-10-17):.ds-scroll-area 的宽度由内容决定——它的 padding-left 可达 410px、
// clientWidth 1733,用它算会形成正反馈(表格越宽 → 容器越宽 → 表格更宽),
// 曾把 4 列表格撑到 1269px 并溢出 912px 的消息体。故以 .ds-markdown 为约束源。
function getTableAvailableWidth(table) {
const md = table.closest('.ds-markdown');
if (md) {
const w = md.clientWidth;
if (w > 0) {
const cs = getComputedStyle(md);
const pad = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
if (w - pad > 0) return w - pad;
}
}
const vc = document.querySelector('.ds-virtual-list-visible-items');
if (vc && vc.clientWidth > 0) return vc.clientWidth;
const host = table.parentElement;
if (host && host.clientWidth > 0) {
const cs = getComputedStyle(host);
const pad = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
if (host.clientWidth - pad > 0) return host.clientWidth - pad;
}
return Math.max(240, window.innerWidth - 96);
}
// 用 落盘列宽。注意 的 width 在 table-layout:fixed 下按 border-box 计算
// (实测含 padding+border),必须补回每列 34px,否则整表会被浏览器按比例放大。
// targetTotal:期望的列宽总和(px)。逐列 Math.round 会产生 ±n/2 的累积误差,
// 超出容器 1px 就足以让官网 .ds-scroll-area 的 overflow-x:auto 冒出一条无意义的滚动条
// (实测 77+683+153=913 vs 容器 912)。这里把误差补到最长的一列,视觉不可见。
function applyColGroup(table, contentWidths, targetTotal) {
let cg = table.querySelector(':scope > colgroup');
if (!cg) {
cg = document.createElement('colgroup');
table.insertBefore(cg, table.firstChild);
}
while (cg.firstChild) cg.removeChild(cg.firstChild);
// 取整用 ceil(-0.01 防整数被进位):Math.round 可能少 0.5px,
// 恰好等于文本宽度的列会因为这点误差折行;ceil 保证不小于分配值。
const px = contentWidths.map(w => Math.max(1, Math.ceil(w - 0.01)) + TABLE_CELL_FIXED);
if (targetTotal > 0) {
const diff = Math.round(targetTotal) - px.reduce((a, b) => a + b, 0);
if (diff !== 0) {
let idx = 0;
for (let i = 1; i < px.length; i++) if (px[i] > px[idx]) idx = i;
px[idx] = Math.max(TABLE_CELL_FIXED + 1, px[idx] + diff);
}
}
px.forEach(w => {
const col = document.createElement('col');
col.style.width = w + 'px';
cg.appendChild(col);
});
}
// 横向滚动:官网有两代结构,必须分别处理。
// ① 新结构:表格过宽时官网自己套一层 .markdown-table-wrapper(overflow-x:auto,
// 用**原生**滚动条,实测 sbH=15px)——这里只加类名去美化那条原生条,
// 宽度约束由官网负责,我们不要改它的 max-width。
// ② 老结构:没有 wrapper 时,滚动壳是 .ds-scroll-area(scrollbar-width:none 隐藏原生条 +
// 自绘 .ds-scroll-area__horizontal-bar,默认全透明)——这里既要加 max-width 约束,
// 也要给自绘条上色。
// ⚠ 绝不能把带 ::-webkit-scrollbar 的类名加到 .ds-scroll-area 上:会唤醒它被隐藏的原生条。
const CLS_SCROLL_WRAPPER = 'ds-table-scroll-wrapper';
const CLS_SCROLL_HOST = 'ds-table-scroll-host';
const _scrollHostOf = new WeakMap(); // table → 当前挂了类名的容器
function setTableScrollHost(table, needScroll) {
if (needScroll) {
const wrapper = table.closest('.markdown-table-wrapper');
const host = wrapper || table.closest('.ds-scroll-area') || table.parentElement;
if (!host) return;
// 宿主的「代」可能切换(官网随后套上 wrapper):先把旧宿主上的类名与内联还原,
// 否则旧容器会永久残留滚动条样式。
const prev = _scrollHostOf.get(table);
if (prev && prev !== host) {
prev.classList.remove(CLS_SCROLL_WRAPPER, CLS_SCROLL_HOST);
if (prev.dataset.dsOrigMaxWidth !== undefined) {
prev.style.maxWidth = prev.dataset.dsOrigMaxWidth;
delete prev.dataset.dsOrigMaxWidth;
prev.scrollLeft = 0;
}
}
if (wrapper) {
wrapper.classList.add(CLS_SCROLL_WRAPPER);
wrapper.classList.remove(CLS_SCROLL_HOST);
} else {
if (host.dataset.dsOrigMaxWidth === undefined) {
host.dataset.dsOrigMaxWidth = host.style.maxWidth || '';
}
host.style.maxWidth = '100%';
host.classList.add(CLS_SCROLL_HOST);
host.classList.remove(CLS_SCROLL_WRAPPER);
}
_scrollHostOf.set(table, host);
return;
}
const prev = _scrollHostOf.get(table);
if (!prev) return;
prev.classList.remove(CLS_SCROLL_WRAPPER, CLS_SCROLL_HOST);
if (prev.dataset.dsOrigMaxWidth !== undefined) {
prev.style.maxWidth = prev.dataset.dsOrigMaxWidth;
delete prev.dataset.dsOrigMaxWidth;
prev.scrollLeft = 0;
}
_scrollHostOf.delete(table);
}
function applyTableStyles(table) {
// 流式构建中可能只出现了 还没有任何单元格:此时无从对齐列宽,直接跳过。
// 指纹状态机会在单元格出现后(指纹变化)重新调度,不会永久漏处理。
const firstRow = table.rows[0];
if (!firstRow || !firstRow.cells.length) return;
// 列数按首行展开 colspan;Markdown 表格通常等于 cells.length
let cols = 0;
for (const cell of firstRow.cells) cols += (cell.colSpan || 1);
if (!cols) return;
const avail = getTableAvailableWidth(table);
if (!avail) return;
// 虚拟列表容器放开裁剪(表格变宽时不被截断)
const vc = document.querySelector('.ds-virtual-list-visible-items');
if (vc) {
vc.style.overflowX = 'visible';
vc.style.maxWidth = '100%';
}
if (tableWidthAlgo === 'equal') {
// 均分:每列等宽。 的百分比在固定布局下按 border-box 解释,
// 与原先「给 td 写百分比 + content-box 压缩」的最终视觉一致。
let cg = table.querySelector(':scope > colgroup');
if (cg) cg.remove();
cg = document.createElement('colgroup');
const per = (100 / cols).toFixed(4) + '%';
for (let i = 0; i < cols; i++) {
const col = document.createElement('col');
col.style.width = per;
cg.appendChild(col);
}
table.insertBefore(cg, table.firstChild);
// 宽度必须同时尊重「表格宽度」设置:
// 铺满 = 用满可用宽;紧凑 = 收窄到「每列都等宽、且能单行放下最长内容」的最小宽度。
if (tableFill === 'compact') {
const nat = measureColumnNaturals(table, cols);
const widest = nat ? nat.reduce((a, b) => Math.max(a, b), 0) : 0;
const need = widest > 0 ? (widest + TABLE_CELL_FIXED) * cols : avail;
table.style.width = Math.min(avail, Math.round(need)) + 'px';
} else {
table.style.width = avail + 'px';
}
table.style.minWidth = '';
table.style.maxWidth = avail + 'px';
setTableScrollHost(table, false);
} else {
const budget = Math.max(0, avail - TABLE_CELL_FIXED * cols);
const natural = measureColumnNaturals(table, cols);
if (!natural) {
// 取不到度量环境(极端情况):退回均分,保证表格可见且不溢出
const per = Math.max(1, (avail - TABLE_CELL_FIXED * cols) / cols);
applyColGroup(table, new Array(cols).fill(per), avail);
table.style.width = avail + 'px';
table.style.minWidth = '';
table.style.maxWidth = avail + 'px';
setTableScrollHost(table, false);
table.style.opacity = '1';
return;
}
const plan = planColumnWidths(natural, cols, budget, {
fill: tableFill === 'fill',
overflow: tableOverflow,
});
// 列宽落盘时用 ceil,取整后的总和会比浮点总和大 0~n px。若表格宽度仍按浮点总宽给,
// 浏览器会把列按比例压回去(实测每列少 0.5px,内容重新差一丝折行)。
// 故表格宽度一律按「取整后的实际总和」pxTotal 来定。
const pxTotal = plan.widths.reduce((a, w) => a + Math.max(1, Math.ceil(w - 0.01)), 0)
+ TABLE_CELL_FIXED * cols;
const targetTotal = plan.overflow
? 0
: (tableFill === 'compact' ? Math.min(avail, pxTotal) : avail);
applyColGroup(table, plan.widths, targetTotal);
if (plan.overflow) {
// 每个列宽不低于可读下限,整表溢出 → 由最近的滚动壳承担横向滚动
table.style.width = pxTotal + 'px';
table.style.minWidth = pxTotal + 'px';
table.style.maxWidth = 'none';
} else if (tableFill === 'compact') {
// 内容紧凑:表格随内容收窄,上限仍为容器宽
table.style.width = Math.min(avail, pxTotal) + 'px';
table.style.minWidth = '';
table.style.maxWidth = avail + 'px';
} else {
// 铺满整行
table.style.width = avail + 'px';
table.style.minWidth = '';
table.style.maxWidth = avail + 'px';
}
setTableScrollHost(table, plan.overflow);
}
if (getComputedStyle(table).position !== 'relative') table.style.position = 'relative';
table.querySelectorAll('th,td').forEach(cell => {
cell.style.whiteSpace = 'normal';
cell.style.overflowWrap = 'anywhere';
cell.style.wordBreak = 'break-word';
});
// 所有处理完成,显示表格
table.style.opacity = '1';
// 表格宽度变了,导出按钮的跟随偏移需要重算
syncTableButtonPositions();
}
// 深拷贝表格并清洗脚本注入的内联样式/辅助节点,供各导出格式(PNG/CSV/MD)复用;
// 使导出内容回归“auto”布局、不污染页面 DOM,也避免各导出路径各自复制一份清洗逻辑。
// opts.keepColGroup = true 时保留 (PNG 需要,用于复现页面的列宽分配),
// 此时调用方负责把列宽换算到目标宽度并显式设置 table 宽度。
function getCleanTableClone(table, opts) {
const keepColGroup = !!(opts && opts.keepColGroup);
const clone = table.cloneNode(true);
// 恢复默认可见性,并移除导出按钮容器与脚本注入标记
clone.style.opacity = '1';
const btns = clone.querySelector('.table-internal-buttons');
if (btns) btns.remove();
clone.removeAttribute('data-internal-buttons-added');
// 默认移除列宽落盘( 是脚本注入的,CSV/MD 只需要文本);
// PNG 走 keepColGroup,保留页面上的列宽比例。
if (!keepColGroup) {
const cg = clone.querySelector('colgroup');
if (cg) cg.remove();
}
// 清洗 applyTableStyles 注入的内联样式(含 fixed 布局相关),让导出内容用到干净样式
clone.style.tableLayout = '';
clone.style.width = '';
clone.style.maxWidth = '';
clone.style.minWidth = '';
clone.style.position = '';
clone.querySelectorAll('th,td').forEach(cell => {
cell.style.width = '';
cell.style.minWidth = '';
cell.style.whiteSpace = '';
cell.style.overflowWrap = '';
cell.style.wordBreak = '';
});
return clone;
}
// 圆角矩形路径(手写 arcTo,避免依赖较新的 ctx.roundRect;半径传 [左上, 右上, 右下, 左下])
function roundRectPath(ctx, x, y, w, h, r) {
const [tl, tr, br, bl] = r;
ctx.moveTo(x + tl, y);
ctx.lineTo(x + w - tr, y); ctx.arcTo(x + w, y, x + w, y + tr, tr);
ctx.lineTo(x + w, y + h - br); ctx.arcTo(x + w, y + h, x + w - br, y + h, br);
ctx.lineTo(x + bl, y + h); ctx.arcTo(x, y + h, x, y + h - bl, bl);
ctx.lineTo(x, y + tl); ctx.arcTo(x, y, x + tl, y, tl);
ctx.closePath();
}
// PNG 导出后处理:把 canvas 四角裁成透明圆角。
// 背景:html2canvas 会把 table 的 border-radius + overflow:hidden 裁成圆角,但被裁处露出的是画布底色
// (backgroundColor:'#ffffff')→ 导出图在深色纸张/文档上是「白色直角」,看着像没有圆角。
// 这里保留白底(auto 主题的半透明叠加色需要纸底衬托),只把圆角之外裁成透明。
// 关键:html2canvas 会在返回的 canvas 上残留变换矩阵(实测 scale=3 → matrix(3,0,0,3,-48,-48),
// 其中 -48 = 导出 iframe 的 body margin 16px × 3);不先重置为单位矩阵,蒙版会被放大+平移,裁剪完全失效。
function applyRoundedCorners(canvas, radiiCss, elemWidthCss) {
if (!canvas || !elemWidthCss) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
if (!w || !h) return;
const k = w / elemWidthCss; // canvas 像素 / CSS 像素(不写死 scale,兼容日后调整)
const lim = Math.min(w, h) / 2;
const [tl, tr, br, bl] = radiiCss.map(r => Math.max(0, Math.min((r || 0) * k, lim)));
if (!tl && !tr && !br && !bl) return; // 表格无圆角 → 保持原样
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); // 清掉 html2canvas 残留变换(漏掉这步裁剪会失效)
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
roundRectPath(ctx, 0, 0, w, h, [tl, tr, br, bl]);
ctx.fillStyle = '#000';
ctx.fill();
ctx.restore();
}
// 导出图片的「纸底」色:跟随页面当前深浅,避免深色主题下深色表头压在纯白底上。
// auto 模式的表头/条纹是半透明叠加色(rgba(...,0.08) 之类),必须有一层不透明纸底衬托,
// 所以这里只接受不透明背景色:从 body 沿祖先链(含 html)取第一个不透明背景;
// 都取不到时按主题兜底(深 #1a1a22 / 浅 #ffffff)。
function getExportCanvasBg() {
const isOpaque = (c) => {
if (!c || c === 'transparent') return false;
const m = /^rgba?\(([^)]+)\)$/.exec(c);
if (!m) return true; // 关键字色(如 white)视为不透明
const p = m[1].split(',').map(s => parseFloat(s));
return p.length < 4 || p[3] === 1; // 半透明底色不采纳(会重演整图半透明)
};
let el = document.body;
while (el) {
const bg = getComputedStyle(el).backgroundColor;
if (isOpaque(bg)) return bg;
el = el.parentElement;
}
return document.body.classList.contains('dark') ? '#1a1a22' : '#ffffff';
}
// 把克隆表格的列宽从「页面宽度」等比换算到「导出宽度」。
// 两种列宽单位都要处理:
// · px(智能模式):按 targetW / srcW 等比缩放;
// · %(均分模式):按百分比直接换算成 targetW 的占比。
// ⚠ parseFloat('33.3333%') 会得到 33.3333,若当 px 处理会把三列压成 [33,33,846]。
// 逐列取整后把误差补到最后一列,保证 Σcol 精确等于目标宽度(否则导出图会出现 1px 溢出)。
function scaleCloneColumns(clone, srcW, targetW) {
const colEls = [...clone.querySelectorAll('col')];
if (!colEls.length) return [];
const k = targetW / Math.max(1, srcW);
const pctMode = /%$/.test((colEls[0].style.width || '').trim());
const out = [];
let acc = 0;
colEls.forEach((col, i) => {
const raw = (col.style.width || '').trim();
const num = parseFloat(raw);
if (!isFinite(num) || num <= 0) return;
const want = pctMode ? (targetW * num / 100) : (num * k);
const scaled = (i === colEls.length - 1)
? Math.max(1, targetW - acc)
: Math.max(1, Math.round(want));
col.style.width = scaled + 'px';
acc += scaled;
out.push(scaled);
});
clone.style.width = targetW + 'px';
clone.style.tableLayout = 'fixed';
return out;
}
async function exportTableAsPNG(table) {
if (!window.html2canvas) { alert('html2canvas 未加载'); return; }
let iframe = null;
try {
// 导出宽度:与页面所见 1:1(页面列宽来自脚本的 );
// 超过上限时等比缩小,避免超宽表生成过大的画布。
const srcW = Math.max(1, Math.round(table.getBoundingClientRect().width));
const targetW = Math.min(srcW, TABLE_EXPORT_MAX_WIDTH);
// 深拷贝表格:保留 colgroup,使导出图的列宽分配与页面一致
const clone = getCleanTableClone(table, { keepColGroup: true });
scaleCloneColumns(clone, srcW, targetW);
// 收集页面上表格相关样式(全局注入 + DeepSeek 变量);传入源表格以便抄其单元格文字色
const styles = collectTableStyles(table);
// 构建隔离 iframe:宽度按导出目标宽度给足(含 body 左右 margin 16×2)
iframe = document.createElement('iframe');
iframe.style.cssText =
'position:fixed;top:-9999px;left:-9999px;width:' + (targetW + 32) + 'px;height:600px;';
iframe.srcdoc = `
${clone.outerHTML}`;
document.body.appendChild(iframe);
// 等待 iframe 加载完成
await new Promise((resolve, reject) => {
iframe.onload = resolve;
iframe.onerror = reject;
setTimeout(resolve, 3000); // 超时保护
});
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
const iframeTable = iframeDoc.querySelector('table');
if (!iframeTable) throw new Error('iframe 中未找到表格元素');
// 截图前读取四角圆角半径与元素 CSS 宽度(iframe 尚在文档中,样式可读)
const tcs = (iframeDoc.defaultView || window).getComputedStyle(iframeTable);
const radiiCss = ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius']
.map(p => parseFloat(tcs[p]) || 0);
const elemWidthCss = iframeTable.getBoundingClientRect().width;
const canvas = await html2canvas(iframeTable, {
scale: 3, // 提升 PNG 导出分辨率(v4.7.0)
backgroundColor: getExportCanvasBg(), // 纸底跟随页面深浅(深色页面不再压在纯白底上)
logging: false,
});
// 按「导出图片圆角」开关决定是否把四角裁成透明圆角(白底保留在圆角内;须在 toBlob 之前完成)
if (tableExportRounded) applyRoundedCorners(canvas, radiiCss, elemWidthCss);
// 导出
canvas.toBlob(blob => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.download = `table_${Date.now()}.png`;
a.href = url;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
} else {
// toBlob 返回 null,回退 dataURL
try {
const dataUrl = canvas.toDataURL('image/png');
fetch(dataUrl).then(r => r.blob()).then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.download = `table_${Date.now()}.png`;
a.href = url;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
}).catch(() => alert('导出PNG失败:无法生成图片数据'));
} catch (_) {
alert('导出PNG失败:canvas 被污染,无法导出');
}
}
}, 'image/png');
} catch (e) {
console.error('PNG导出异常:', e);
alert('导出PNG失败:' + (e.message || '未知错误'));
} finally {
if (iframe) setTimeout(() => iframe.remove(), 200);
}
}
// 收集页面上表格所需的样式,注入 iframe
function collectTableStyles(sourceTable) {
let css = '';
const isDark = document.body.classList.contains('dark');
const mode = tableThemeMode;
// 单元格文字色 / 链接 / 行内代码:导出的 iframe 是一个没有 .ds-markdown 祖先、也不继承页面
// color 的空白文档 —— 页面上那些 `.ds-markdown ...` 前缀的规则在其中根本不匹配。若不显式取值,
// 深色纸底会落回浏览器默认:黑字、#0000EE 蓝链接(带下划线)、灰底 code,与页面观感明显不符。
// 直接抄页面上该表格对应元素的实际计算样式最保真(取不到再按主题兜底)。
let thColor = '', tdColor = '', aColor = '', aDeco = '', codeColor = '', codeBg = '';
try {
const probeTh = sourceTable && sourceTable.querySelector('th');
const probeTd = sourceTable && sourceTable.querySelector('td');
const probeA = sourceTable && sourceTable.querySelector('a');
const probeCode = sourceTable && sourceTable.querySelector('code');
if (probeTh) thColor = getComputedStyle(probeTh).color;
if (probeTd) tdColor = getComputedStyle(probeTd).color;
if (probeA) {
const sa = getComputedStyle(probeA);
aColor = sa.color;
aDeco = sa.textDecorationLine;
}
if (probeCode) {
const sc = getComputedStyle(probeCode);
codeColor = sc.color;
codeBg = sc.backgroundColor;
}
} catch (_) { /* 取不到就退回主题兜底色 */ }
const fallbackColor = isDark ? '#e4e4e8' : '#1f2937';
// code 背景:页面上取到的不透明底色优先,否则退回半透明灰(浅底上是浅灰、深底上是微亮块)
const codeBgCss = (!codeBg || codeBg === 'transparent' || codeBg === 'rgba(0, 0, 0, 0)')
? 'rgba(128,128,128,0.1)' : codeBg;
// 从页面提取表格相关样式(.ds-markdown 表格部分,含脚本注入的规则)
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules || []) {
const txt = rule.cssText;
if (txt.includes('table') || txt.includes('th') || txt.includes('td') ||
txt.includes('.ds-markdown') || txt.includes('.md-code-block')) {
// 跳过脚本自己注入的 fixed 布局和导出按钮样式
if (txt.includes('table-layout: fixed') || txt.includes('table-internal-buttons')) continue;
css += txt + '\n';
}
}
} catch (_) {
// 跨域样式表无法读取,忽略
}
}
// 基础表格样式(兜底,根据当前主题模式选择配色)
const bodyBg = getComputedStyle(document.body).backgroundColor || '#ffffff';
css += /*css*/`
body { background: ${bodyBg}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
table {
width: 100%; border-collapse: separate; border-spacing: 0;
margin: 1em 0; border-radius: 12px; overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
th, td {
padding: 12px 16px; vertical-align: top;
font-size: 14px; line-height: 1.5;
white-space: normal; word-wrap: break-word;
color: ${tdColor || fallbackColor};
}
th { font-weight: 600; color: ${thColor || tdColor || fallbackColor}; }
${tableHeadAlign === 'center' ? 'th { text-align: center; }' : ''}
/* 链接:抄页面的实际色与下划线(iframe 内 .ds-markdown a 规则不匹配,否则落到默认 #0000EE + 下划线) */
a { color: ${aColor || 'inherit'};${aDeco ? ` text-decoration: ${aDeco};` : ''} }
/* 单元格内联代码的兜底样式(PNG iframe 导出图里的 code) */
table code {
background: ${codeBgCss}; padding: 2px 4px;
border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em;
${codeColor ? `color: ${codeColor};` : ''}
}
${mode === 'auto' ? /* 自动透明叠加 */`
th, td { border: 1px solid rgba(128,128,128,0.2); }
th { background: rgba(128,128,128,0.08); border-bottom: 1px solid rgba(128,128,128,0.2); }
tbody tr:nth-child(even) { background-color: rgba(128,128,128,0.04); }
` : isDark ? /* 双模式 — 深色 */`
th, td { border: 1px solid #2d2d3d; }
th { background: #1e1e2d; border-bottom: 1px solid #2d2d3d; color: #e4e4e8; }
tbody tr:nth-child(even) { background-color: rgba(255,255,255,0.03); }
` : /* 双模式 — 浅色 */`
th, td { border: 1px solid #e5e7eb; }
th { background: #f3f4f6; border-bottom: 1px solid #e5e7eb; color: #1f2937; }
tbody tr:nth-child(even) { background-color: #fafafa; }
`}
`;
return css;
}
function exportTableAsCSV(table) {
const clone = getCleanTableClone(table); // 基于清洗克隆,避免读取页面注入样式/按钮
const rows = [];
const thead = clone.querySelector('thead');
if (thead) thead.querySelectorAll('tr').forEach(tr => {
const rd = []; tr.querySelectorAll('th').forEach(th => rd.push(getCellText(th)));
if (rd.length) rows.push(rd);
});
const tbody = clone.querySelector('tbody');
if (tbody) tbody.querySelectorAll('tr').forEach(tr => {
const rd = []; tr.querySelectorAll('td').forEach(td => rd.push(getCellText(td)));
if (rd.length) rows.push(rd);
});
else clone.querySelectorAll('tr').forEach(tr => {
const rd = []; tr.querySelectorAll('td,th').forEach(c => rd.push(getCellText(c)));
if (rd.length) rows.push(rd);
});
if (!rows.length) { alert('无数据'); return; }
const csv = rows.map(r => r.map(c => {
if (c.includes(',') || c.includes('"') || c.includes('\n')) c = '"' + c.replace(/"/g,'""') + '"';
return c;
}).join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `table_${Date.now()}.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 100);
}
// 导出为 Markdown 表格并复制到剪贴板(📝)
function exportTableAsMD(table) {
const clone = getCleanTableClone(table);
const rows = [];
const thead = clone.querySelector('thead');
if (thead) thead.querySelectorAll('tr').forEach(tr => {
const rd = []; tr.querySelectorAll('th').forEach(th => rd.push(getCellTextForMarkdown(th)));
if (rd.length) rows.push(rd);
});
// 有表头时追加分隔行
if (rows.length) rows.push(rows[0].map(() => '---'));
const tbody = clone.querySelector('tbody');
if (tbody) tbody.querySelectorAll('tr').forEach(tr => {
const rd = []; tr.querySelectorAll('td').forEach(td => rd.push(getCellTextForMarkdown(td)));
if (rd.length) rows.push(rd);
});
else clone.querySelectorAll('tr').forEach((tr, idx) => {
const rd = []; tr.querySelectorAll('td,th').forEach(c => rd.push(getCellTextForMarkdown(c)));
if (!rd.length) return;
if (idx === 0 && rows.length === 0) rows.push(rd.map(() => '---')); // 无表头:首行后补分隔
rows.push(rd);
});
if (!rows.length) { alert('无数据'); return; }
const md = rows.map(r => '| ' + r.join(' | ') + ' |').join('\n');
navigator.clipboard.writeText(md)
.then(() => showToast('表格已复制为 Markdown'))
.catch(() => alert('复制失败,请手动复制'));
}
// 提取带 Markdown 行内语法(`code` / **加粗** / *斜体*)的单元格文本,并对管道符做转义;
// 单元格内的
因会破坏表格“一行一格”结构,这里折叠为普通空格(与 CSV 的换行保留策略不同)
function getCellTextForMarkdown(cell) {
let text = '';
cell.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) text += n.textContent;
else if (n.nodeName === 'BR') text += '\n';
else if (n.nodeType === Node.ELEMENT_NODE) {
if (n.tagName === 'CODE') text += '`' + n.textContent + '`';
else if (n.tagName === 'STRONG' || n.tagName === 'B') text += '**' + n.textContent + '**';
else if (n.tagName === 'EM' || n.tagName === 'I') text += '*' + n.textContent + '*';
else text += getCellTextForMarkdown(n);
}
});
return text.replace(/[^\S\n]+/g, ' ').replace(/ *\n */g, ' ').trim()
.replace(/\|/g, '\\|');
}
function getCellText(cell) {
let t = '';
cell.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) t += n.textContent;
else if (n.nodeName === 'BR') t += '\n';
else if (n.nodeType === Node.ELEMENT_NODE) t += getCellText(n);
});
// 保留
产生的换行,压缩其他空白字符
t = t.replace(/[^\S\n]+/g, ' ').replace(/ *\n */g, '\n').trim();
return t;
}
// 按钮组挂在**消息体**上而不是 内:
// ① 表格有 overflow:hidden(圆角裁剪),放在表格内部的「外部」部分会被裁掉;
// ② absolute 相对表格定位只能贴表格边界内侧,做不到「表格右下角之外」。
const _btnsOfTable = new WeakMap(); // table → 按钮容器
const _tableOfBtns = new WeakMap(); // 按钮容器 → table
function addButtonsToTable(table) {
if (!tableButtonsEnabled) return;
const existing = _btnsOfTable.get(table);
if (existing && existing.isConnected) return; // 已挂且仍在文档中
if (existing) existing.remove();
const host = table.closest('.ds-markdown') || table.parentElement;
if (!host) return;
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
table.setAttribute('data-internal-buttons-added', 'true');
const bc = document.createElement('div');
bc.className = 'table-internal-buttons';
// 导出按钮图标(iconify mdi SVG);emoji 作加载失败兜底(二者择一,不叠加)
const makeExportBtn = (emoji, icon, tooltip, onClick) => {
const b = document.createElement('button');
b.className = 'internal-export-btn';
b.setAttribute('data-tooltip', tooltip);
const showEmoji = () => { b.innerHTML = ''; b.textContent = emoji; };
const im = document.createElement('img');
im.className = 'export-btn-ic';
im.alt = '';
im.draggable = false;
im.src = 'https://api.iconify.design/mdi:' + icon + '.svg?color=%235b6472';
im.addEventListener('error', showEmoji);
b.appendChild(im);
b.addEventListener('click', e => { e.stopPropagation(); onClick(); });
return b;
};
const pngBtn = makeExportBtn('📸', 'image-outline', '导出为 PNG', () => exportTableAsPNG(table));
const csvBtn = makeExportBtn('📄', 'file-delimited-outline', '导出为 CSV', () => exportTableAsCSV(table));
const mdBtn = makeExportBtn('📝', 'language-markdown', '导出为 Markdown', () => exportTableAsMD(table));
bc.appendChild(pngBtn); bc.appendChild(csvBtn); bc.appendChild(mdBtn);
host.appendChild(bc);
_btnsOfTable.set(table, bc);
_tableOfBtns.set(bc, table);
bindTableButtonHover(table, bc);
positionTableButtons(table, bc);
}
// 悬停控制:按钮在表格之外,紧靠 `table:hover` 无法覆盖按钮区,
// 鼠标穿过 12px 间隙时会瞬间丢失悬停 → 按钮消失、点不到。
// 这里把「表格」与「按钮组(含其 padding 桥接区)」合并成一个悬停域,
// 并用 140ms 延迟隐藏吸收短暂的指针越界。
function bindTableButtonHover(table, bc) {
let hideTimer = null;
const show = () => { clearTimeout(hideTimer); bc.classList.add('is-visible'); };
const hide = () => {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => bc.classList.remove('is-visible'), 140);
};
table.addEventListener('mouseenter', show);
table.addEventListener('mouseleave', hide);
bc.addEventListener('mouseenter', show);
bc.addEventListener('mouseleave', hide);
}
// 位置:表格右下角之外。left 取 min(表格右边缘, 消息区宽度) ——
// 内容紧凑时贴表格右边缘;铺满时停在消息区边界(视觉上在消息区外);
// 表格横向溢出时也不越过消息区,按钮始终可见。
function positionTableButtons(table, bc) {
const host = bc.parentElement;
if (!host || !table.isConnected) return;
// 只由 left/top 控制:若同时存在 right/bottom(旧样式或残留),absolute 元素会被拉伸,
// 实测 offsetHeight 从 112 变 629,按钮组位置全错。
bc.style.right = 'auto';
bc.style.bottom = 'auto';
const tr = table.getBoundingClientRect();
const mr = host.getBoundingClientRect();
const hostW = host.clientWidth || mr.width;
const left = Math.min(tr.right - mr.left, hostW);
const h = bc.offsetHeight || 112; // 3×32 + 2×8,未完成布局时兜底
bc.style.left = Math.round(left) + 'px';
bc.style.top = Math.round(tr.bottom - mr.top - h) + 'px';
}
// 批量重排按钮位置(rAF 合帧):表格宽/高变化、窗口 resize 后调用。
// 位置由 positionTableButtons 按几何计算,横向滚动时 left 已被 min 收敛到消息区边界,无需监听 scroll。
let _btnSyncRaf = null;
function syncTableButtonPositions() {
if (_btnSyncRaf) return;
_btnSyncRaf = requestAnimationFrame(() => {
_btnSyncRaf = null;
document.querySelectorAll('.table-internal-buttons').forEach(bc => {
const table = _tableOfBtns.get(bc);
if (table && table.isConnected) positionTableButtons(table, bc);
});
});
}
// 表格指纹追踪:记录每个表格的稳定计数,连续 2 次指纹不变即视为稳定
const _tableFingerprints = new WeakMap(); // table → { fp, count, firstSeen }
const STABLE_COUNT_NEEDED = 2; // 连续稳定次数阈值
const MAX_WAIT_MS = 5000; // 最长等待时间,超时强制应用
const TABLE_DEBOUNCE_MS = 200; // Observer 批处理间隔
function getTableFingerprint(table) {
const rows = table.querySelectorAll('tr').length;
const cells = table.querySelectorAll('td,th').length;
return rows + ':' + cells;
}
function processAllTables() {
let anyUnstable = false;
const now = Date.now();
document.querySelectorAll('.ds-markdown').forEach(container => {
container.style.overflowX = 'visible';
container.style.maxWidth = '100%';
container.querySelectorAll('table').forEach(table => {
const fp = getTableFingerprint(table);
const state = _tableFingerprints.get(table);
if (!state) {
// 首次见到:立即应用并标记完成
_tableFingerprints.set(table, { fp, count: STABLE_COUNT_NEEDED, firstSeen: now, done: true });
applyTableStyles(table);
addButtonsToTable(table);
return;
}
if (state.done) {
// 已稳定应用过,指纹未变则跳过
if (fp === state.fp) return;
// 指纹变了(流式输出新增行/列),重置重新等待
state.fp = fp;
state.count = 0;
state.done = false;
anyUnstable = true;
return;
}
if (fp !== state.fp) {
// 指纹变化:重置计数,重新等待稳定
state.fp = fp;
state.count = 0;
anyUnstable = true;
return;
}
// 指纹相同:增加稳定计数
state.count++;
const timedOut = (now - state.firstSeen) > MAX_WAIT_MS;
if (state.count >= STABLE_COUNT_NEEDED || timedOut) {
// 达到稳定阈值或超时兜底:应用样式并标记完成
state.count = STABLE_COUNT_NEEDED;
state.done = true;
if (timedOut) state.firstSeen = now; // 重置计时,防止永久超时
applyTableStyles(table);
addButtonsToTable(table);
} else {
anyUnstable = true;
}
});
});
if (anyUnstable) {
scheduleTableProcess();
}
}
let _tableDebounceTimer = null;
function scheduleTableProcess() {
clearTimeout(_tableDebounceTimer);
_tableDebounceTimer = setTimeout(processAllTables, TABLE_DEBOUNCE_MS);
}
// 可用宽度变了(宽屏切换 / 窗口缩放 / 浏览器缩放 / 侧栏收放)→ 所有表格必须按新宽度重算。
// 列宽与表宽都是**绝对 px 快照**(table.style.width + 逐列 px),一旦容器容量变化,
// 旧快照就会溢出。而指纹只含「行数:单元格数」,宽度变化不在指纹里 —— processAllTables 会对
// 已完成的表格直接 return。故这里先清掉 done 标记,让它们走「首次见到」分支立即重排。
// ⚠ 若只调 processAllTables 而不清指纹,等于什么都没做(resize 回调曾因此长期空转)。
function invalidateTableLayout() {
document.querySelectorAll('.ds-markdown table').forEach(t => _tableFingerprints.delete(t));
processAllTables();
syncTableButtonPositions();
}
// ==================== AI思考区域自动折叠逻辑 ====================
function collapseThinkingSection(container) {
if (!autoCollapseThinking || container.hasAttribute('data-thinking-collapsed')) return;
container.setAttribute('data-thinking-collapsed', 'true');
if (simulateClickThinking) {
// 模拟点击折叠箭头(保持原生交互)
let clickableArrow = null;
const icons = container.querySelectorAll('.ds-icon');
if (icons.length >= 2) clickableArrow = icons[icons.length-1];
else if (icons.length === 1) clickableArrow = icons[0];
if (!clickableArrow) {
const svg = container.querySelector('svg');
if (svg && svg.parentElement) clickableArrow = svg.parentElement;
}
if (clickableArrow && typeof clickableArrow.click === 'function') {
setTimeout(() => clickableArrow.click(), 100);
}
} else {
// CSS 直接折叠:隐藏思考内容区域
const thinkContent = findThinkContent(container);
if (thinkContent) {
thinkContent.dataset.dsScriptCollapsed = 'true';
thinkContent.style.display = 'none';
}
}
}
// 从标题栏容器向上找到对应的 .ds-think-content 元素
function findThinkContent(titleBar) {
// 向上查找 collapsible 包装器,再找其中的 think content
let parent = titleBar.parentElement;
while (parent && parent !== document.body) {
const tc = parent.querySelector('.ds-think-content');
if (tc) return tc;
parent = parent.parentElement;
}
return null;
}
let _thinkHideStyle = null;
let _thinkCaptureAdded = false;
// 预隐藏思考内容(CSS 拦截),消除展开→折叠的布局偏移
function setupThinkContentHiding() {
if (!autoCollapseThinking) return;
// 注入 !important 样式使 .ds-think-content 初始不可见
if (!_thinkHideStyle) {
_thinkHideStyle = document.createElement('style');
_thinkHideStyle.id = 'ds-think-hide';
_thinkHideStyle.textContent = '.ds-think-content { display: none !important; }';
document.head.appendChild(_thinkHideStyle);
}
// 用户点击箭头时,在 capture 阶段提前移除 CSS,让 DeepSeek 正常创建可视内容
if (!_thinkCaptureAdded) {
_thinkCaptureAdded = true;
document.addEventListener('click', function dsThinkCapture(e) {
const clickable = e.target.closest('[class*="_5ab5d64"], [class*="c2b72bb8"]');
if (!clickable) return;
if (_thinkHideStyle) {
_thinkHideStyle.remove();
_thinkHideStyle = null;
}
}, true);
}
}
function processAllThinkingSections() {
if (!autoCollapseThinking) return;
// 基于稳定的 .ds-think-content 类名定位思考区域
document.querySelectorAll('.ds-think-content').forEach(thinkContent => {
// 排除代码块内的文本(避免脚本源码中的"已思考"文字误匹配)
if (thinkContent.closest('pre, .md-code-block')) return;
if (thinkContent.dataset.dsScriptCollapsed === 'true') return;
// 向上查找包含标题栏的 collapsible 包装器
let wrapper = thinkContent.parentElement;
while (wrapper && wrapper !== document.body) {
const titleBar = wrapper.querySelector('div[class*="_5ab5d64"]');
if (titleBar && titleBar.textContent && titleBar.textContent.includes('已思考')) {
if (simulateClickThinking) {
collapseThinkingSection(titleBar);
} else {
// CSS 回退:直接隐藏 think content
if (!titleBar.hasAttribute('data-thinking-collapsed')) {
titleBar.setAttribute('data-thinking-collapsed', 'true');
thinkContent.dataset.dsScriptCollapsed = 'true';
thinkContent.style.display = 'none';
}
}
return;
}
wrapper = wrapper.parentElement;
}
// 未找到标题栏时,CSS 回退模式下直接隐藏
if (!simulateClickThinking) {
thinkContent.dataset.dsScriptCollapsed = 'true';
thinkContent.style.display = 'none';
}
});
}
// ==================== 对话文件夹管理(并入自独立脚本,folderManagerEnabled 关闭时完全不注入) ====================
// 封装为独立子闭包 folderUnit,与原脚本最大兼容、不污染外层命名空间;不自持 observer,
// 启停/持续扫描由外层统一开关与 observeDOM 驱动(见 init / observeDOM / openControlPanel)。
const folderUnit = (() => {
/* ==================== 存储(沿用主脚本独立键 v2;v0.9.2 起数据结构含 expanded 展开态) ==================== */
function loadData() {
try {
const d = JSON.parse(GM_getValue(STORAGE_FOLDER_DATA, 'null'));
if (d && Array.isArray(d.folders) && d.links) {
if (!d.expanded || typeof d.expanded !== 'object') d.expanded = {};
if (typeof d.collapsed !== 'boolean') d.collapsed = false; // 面板整体折叠态(v4.7.0 新增)
return d;
}
} catch (e) { /* ignore */ }
return { folders: [], links: {}, expanded: {}, collapsed: false };
}
let data = loadData();
function saveData() { GM_setValue(STORAGE_FOLDER_DATA, JSON.stringify(data)); }
let pinGroupCollapsed = !!GM_getValue(STORAGE_PIN_COLLAPSED, false); // 原生「置顶」分组折叠态
const genId = () => 'f_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
const sessionIdOf = (a) => {
const m = (a.getAttribute('href') || '').match(/\/s\/([^/?#]+)/);
return m ? m[1] : null;
};
const folderNameOf = (fid) => (data.folders.find((x) => x.id === fid) || {}).name || '';
const nativeNodeFor = (sid) => document.querySelector(`a[href$="/s/${sid}"]`);
const PALETTE = ['#7aa2ff', '#ff9e7a', '#7affb0', '#ffd27a', '#d27aff', '#7affe0', '#ff7ab0', '#a0ff7a'];
const folderColor = (id) => {
let h = 0; for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return PALETTE[h % PALETTE.length];
};
function shadeHex(hex, amt) {
const n = parseInt(hex.slice(1), 16);
let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
if (amt < 0) { const f = 1 + amt; r *= f; g *= f; b *= f; }
else { r += (255 - r) * amt; g += (255 - g) * amt; b += (255 - b) * amt; }
return `rgb(${r | 0},${g | 0},${b | 0})`;
}
function parseRGB(s) {
const m = s.match(/(\d+)\D+(\d+)\D+(\d+)/);
return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
}
/* ---------- 主题(保留原脚本逻辑:随官网深/浅色) ---------- */
function isDark() {
const m = document.documentElement.getAttribute('data-mode');
if (m !== null && m !== '') return m !== 'light';
const bg = getComputedStyle(document.body).backgroundColor;
const c = parseRGB(bg);
if (c) return (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) < 140;
return !window.matchMedia('(prefers-color-scheme: light)').matches;
}
function applyTheme() {
const dark = isDark();
const r = document.documentElement.style;
const v = dark
? {
'--ds-text': '#e8eaed', '--ds-sub': '#9aa0a6',
'--ds-hover': 'rgba(255,255,255,.08)', '--ds-active-bg': 'rgba(122,162,255,.18)',
'--ds-accent': '#7aa2ff', '--ds-divider': 'rgba(255,255,255,.12)',
'--ds-border': 'rgba(255,255,255,.14)',
}
: {
'--ds-text': '#0f1115', '--ds-sub': '#81858c',
'--ds-hover': 'rgba(0,0,0,.05)', '--ds-active-bg': 'rgba(56,108,255,.12)',
'--ds-accent': '#3a6cff', '--ds-divider': 'rgba(0,0,0,.10)',
'--ds-border': 'rgba(0,0,0,.14)',
};
for (const k in v) r.setProperty(k, v[k]);
}
/* ---------- 动态样式(v0.9.2 树形样式;on 注入 / off 移除) ---------- */
const FOLDER_CSS_ID = 'ds-folder-css';
const FOLDER_CSS = `
#dsFolderPanel{
font-size:14px; color:var(--ds-text); font-family:inherit;
margin:2px 0 4px; padding:2px 8px 8px 0; /* 对齐原生「分组头→首行」节奏(原生间距 0);仅兜底挂载(无置顶分组)时由 JS 临时补 22px 让开悬浮「多选」按钮带 */
background:transparent; border:none; box-shadow:none; border-radius:0;
user-select:none;
}
/* 原生「选择对话」(多选)模式:文件夹树不参与批量选择 → 整区隐藏,让位给原生批量选择 */
#dsFolderPanel.dsSelectModeHidden{display:none;}
/* 原生「置顶」分组标题可折叠:点击切换。
箭头与「文件夹」标题完全一致(同款 chevron、同色 --ds-sub、收起旋转 -90°),用 mask 绘制而非
往 React 管理的标题里塞节点;hover 也复用文件夹标题的 --ds-hover 底色。
语义:展开 = 朝下 v,折叠 = 朝右 >(基础图形为朝下 chevron,折叠态 rotate(-90°) 即得朝右)。 */
.ds-pin-head{cursor:pointer; border-radius:6px; transition:background .15s ease;}
.ds-pin-head:hover{background:var(--ds-hover);}
.ds-pin-head::after{content:""; display:inline-block; width:15px; height:15px; margin-left:6px;
vertical-align:middle; background-color:var(--ds-sub); transition:transform .15s ease;
-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 6 8 10.5 12.5 6' fill='none' stroke='black' stroke-width='1.6' stroke-linejoin='round'/%3E%3C/svg%3E") center / 15px 15px no-repeat;
mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 6 8 10.5 12.5 6' fill='none' stroke='black' stroke-width='1.6' stroke-linejoin='round'/%3E%3C/svg%3E") center / 15px 15px no-repeat;}
.ds-pin-head.dsPinCollapsed::after{transform:rotate(-90deg);}
#dsFolderPanel .dsfh{display:flex; align-items:center; justify-content:space-between; margin:2px 0 6px; padding-left:10px;}
#dsFolderPanel .dsfh .dsHeadTitle{display:flex; align-items:center; gap:6px; cursor:pointer; padding:3px 8px 3px 5px; margin-left:-5px; border-radius:6px; user-select:none;}
#dsFolderPanel .dsfh .dsHeadTitle:hover{background:var(--ds-hover);}
#dsFolderPanel .dsHeadCaret{width:15px;height:15px;flex:0 0 auto;color:var(--ds-sub);transition:transform .15s ease;}
#dsFolderPanel.collapsed .dsHeadCaret{transform:rotate(-90deg);} /* 展开=朝下 v,折叠=朝右 > */
#dsFolderPanel .dsfh b{font-weight:500; font-size:12px; color:var(--ds-sub); letter-spacing:.4px;}
#dsFolderPanel .dsNew{background:transparent; color:var(--ds-sub);
border:1px solid var(--ds-divider); border-radius:100px;
padding:3px 10px; cursor:pointer; font-size:12px; font-weight:500;}
#dsFolderPanel .dsNew:hover{background:var(--ds-hover); color:var(--ds-text);}
/* 去掉区内自设 300px 独立滚动:让整个文件夹区随原生会话历史一起滚,内部不再单独出滚动条 */
#dsFolderPanel .dsList{display:flex; flex-direction:column; gap:2px;}
#dsFolderPanel.collapsed .dsList{display:none;} /* 面板整体折叠(点标题收起) */
#dsFolderPanel.collapsed .dsNew{display:none;} /* 折叠态也不展示「+ 新建」,只留标题行可展开 */
#dsFolderPanel.collapsed .dsfh{margin-bottom:0;} /* 收起后挤掉与列表的间隙,仅留标题行 */
#dsFolderPanel.collapsed .dsHeadTitle{margin-right:0;} /* 标题占满整行便于再点开 */
#dsFolderPanel .dsItem{display:flex; align-items:center; gap:8px; padding:7px 10px; border-radius:8px;
min-height:35px; box-sizing:border-box;
cursor:pointer; color:var(--ds-text);}
#dsFolderPanel .dsItem:hover{background:var(--ds-hover);}
#dsFolderPanel .dsItem.active{background:var(--ds-active-bg); color:var(--ds-accent);}
#dsFolderPanel .dsItem .dsName{flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500;}
#dsFolderPanel .dsItem .dsCount{opacity:.5; font-size:12px; font-weight:600; margin-left:6px; flex:0 0 auto;}
#dsFolderPanel .dsItem .dsOps{display:flex; gap:6px; flex:0 0 auto; visibility:hidden; margin-left:auto;}
#dsFolderPanel .dsItem:hover .dsOps{visibility:visible;}
#dsFolderPanel .dsOps button{background:none; border:none; color:var(--ds-sub); cursor:pointer; font-size:12px; padding:2px 4px; border-radius:5px;}
#dsFolderPanel .dsOps button:hover{background:var(--ds-hover); color:var(--ds-text);}
#dsFolderPanel .dsEmpty{opacity:.5; font-size:12.5px; padding:6px 10px;}
/* v0.9.0+ 树形文件夹:箭头 / 会话内嵌行 */
#dsFolderPanel .dsCaret{width:20px; height:20px; flex:0 0 auto; background:none; border:none;
padding:0; margin:0; cursor:pointer; color:var(--ds-sub); border-radius:5px; display:flex; align-items:center; justify-content:center;}
#dsFolderPanel .dsCaret:hover{background:var(--ds-hover); color:var(--ds-text);}
#dsFolderPanel .dsFoldBody{display:flex; flex-direction:column;}
#dsFolderPanel .dsConvRow{display:flex; align-items:center; gap:8px; padding:7px 10px 7px 30px; border-radius:8px;
min-height:32px; box-sizing:border-box; cursor:pointer; color:var(--ds-text); font-size:13px; position:relative;}
#dsFolderPanel .dsConvRow:hover{background:var(--ds-hover);}
#dsFolderPanel .dsConvRow.on{color:var(--ds-accent);}
#dsFolderPanel .dsConvBar{display:none; position:absolute; left:18px; top:50%; transform:translateY(-50%);
width:3px; height:15px; border-radius:2px; background:var(--ds-accent); pointer-events:none;}
#dsFolderPanel .dsConvRow.on .dsConvBar{display:block;}
#dsFolderPanel .dsConvTitle{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
#dsFolderPanel .dsOut{background:none; border:1px solid var(--ds-divider); color:var(--ds-sub);
border-radius:7px; padding:2px 9px; cursor:pointer; font-size:12px; flex:0 0 auto;}
#dsFolderPanel .dsOut:hover{color:#ff8585; border-color:#ff8585;}
/* 自绘标题 tooltip:配色/圆角/字号/内边距对齐官网 .ds-tooltip(实测 rgb(44,44,46) / #fff / 12px / 4px 8px / radius 10px) */
.dsFolderTip{position:fixed; z-index:3000; background:#2c2c2e; color:#fff;
font-size:12px; line-height:1.5; padding:4px 8px; border-radius:10px;
max-width:560px; pointer-events:none; word-break:break-all;}
.dsTag{display:inline-block; margin-left:6px; font-size:11px; font-weight:600;
padding:1px 6px; border-radius:5px; vertical-align:middle; line-height:1.4;}
/* 文件夹选择浮层 */
#dsFolderPop .dsmpItem{padding:8px 12px; border-radius:8px; cursor:pointer; color:var(--ds-text);
display:flex; align-items:center; gap:8px; font-size:13.5px;}
#dsFolderPop .dsmpItem:hover{background:var(--ds-hover);}
#dsFolderPop .dsmpSep{height:1px; background:var(--ds-border); margin:4px 0;}
/* 鼠标从官方菜单项滑到「移动到文件夹」后,官方项高亮态残留 */
.ds-dropdown-menu:has([data-ds-move]:hover) .ds-dropdown-menu-option:not([data-ds-move]){
background:transparent !important; box-shadow:none !important; outline:none !important;
}`;
function injectCss() {
if (document.getElementById(FOLDER_CSS_ID)) return;
const st = document.createElement('style');
st.id = FOLDER_CSS_ID;
st.textContent = FOLDER_CSS;
document.head.appendChild(st);
}
function removeCss() { const st = document.getElementById(FOLDER_CSS_ID); if (st) st.remove(); }
/* ---------- 状态(v0.9.x 树形模型:无全局筛选/抽屉;展开态持久化于 data.expanded) ---------- */
let fCurrentMenuSid = null; // 当前打开三点菜单所属的会话
let lastMenuOpenAt = 0; // 最近一次点击打开官方 ⋯ 菜单的时刻(capture click 记录)
let lastRealMoveAt = 0; // 最近一次真实指针位移时刻(捕获阶段记录)
document.addEventListener('pointermove', () => { lastRealMoveAt = Date.now(); }, { capture: true, passive: true });
const isExpanded = (id) => data.expanded[id] !== false; // 默认展开
const toggleExpanded = (id) => { data.expanded[id] = !isExpanded(id); };
/* ---------- 面板定位(挂点沿用 v0.8.1/v0.9.x 稳定方案) ---------- */
function findNewChatBtn() {
const texts = ['开启新对话', '新对话', '开始新对话'];
const span = [...document.querySelectorAll('span')].find((x) => texts.includes(x.textContent.trim()));
if (span) return span.closest('[class*="ds-button"]') || span.parentElement;
return [...document.querySelectorAll('button,div,a')].find((x) => texts.includes((x.textContent || '').trim())) || null;
}
function findScrollContainer() {
// 优先:从任意会话链接向上找 overflow:auto/scroll 祖先(列表滚动容器)
const link = document.querySelector('a[href^="/a/chat/s/"]');
if (link) {
let el = link.parentElement;
while (el) {
const cs = getComputedStyle(el);
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') return el;
el = el.parentElement;
}
}
// 兜底:链接尚未渲染(SPA 加载/导航竞态)时,从「新对话」按钮向上找侧栏根,
// 再于其后代中定位唯一的滚动容器——避免误插到滚动容器外被钉死在顶部。
const btn = findNewChatBtn();
let root = btn;
while (root && root !== document.body) {
const s = [...root.querySelectorAll('*')].find((d) => {
const cs = getComputedStyle(d);
return cs.overflowY === 'auto' || cs.overflowY === 'scroll';
});
if (s) return s;
root = root.parentElement;
}
return null;
}
/* ---- 「置顶」分组容器定位 ----
原生结构:[置顶 sticky 头 → 置顶会话容器 → 分隔线],父级即「置顶」分组容器。
两个用途:① 面板锚点(插在分组容器之后,避免夹在「置顶」标题与其会话之间);
② 归档隐藏时跳过置顶分组内的行(置顶是显式行为,不能因归档进文件夹而从置顶区消失)。 */
let pinnedGroupEl = null;
function findPinnedGroup() {
if (pinnedGroupEl && pinnedGroupEl.isConnected) return pinnedGroupEl;
const sc = findScrollContainer();
if (!sc) return null;
const head = [...sc.querySelectorAll('div')].find((d) => {
const cs = getComputedStyle(d);
return cs.position === 'sticky' && d.clientHeight > 0 && d.clientHeight < 60
&& (d.innerText || '').trim() === '置顶';
});
pinnedGroupEl = (head && head.parentElement) ? head.parentElement : null;
return pinnedGroupEl;
}
function ensurePanel() {
applyTheme();
let panel = document.getElementById('dsFolderPanel');
if (panel) {
// 已存在:沿祖先链判断是否已在滚动容器内。面板经「置顶」sticky 行挂入其所在内容包装层
// (如 _3098d02,overflow:visible),真正的列表 scroller(_6d215eb ds-scroll-area)在其
// 上方若干层——只查直接父级会误判为"未挂载",导致每轮 schedule 重挂 + renderFolders 重建
// .dsList(真实 mutation)→ observer → schedule 的无限刷新循环。
let inScroller = false;
let anc = panel.parentElement;
while (anc && anc !== document.body) {
const ov = getComputedStyle(anc).overflowY;
if (ov === 'auto' || ov === 'scroll') { inScroller = true; break; }
anc = anc.parentElement;
}
if (inScroller) {
// 位置校验(v4.9.1 修正自愈判据):仅当面板**挂在正确锚点**时才认作稳态 → 零扫描早退。
// 此前判据为「不在「置顶」分组内即稳态」,语义过宽:若面板曾被兜底挂到滚动容器最顶部
// (页面早期 sticky「置顶」头尚未渲染时 `findPinnedGroup()` 返回 null 所走的兜底路径),
// 该条件恒成立 → 守卫误判稳态、永不复位,导致面板长期卡在错误位置并残留 22px 死空白。
// 现改为直接核对锚点:有「置顶」分组时面板须紧跟其后;无分组时须为滚动容器首子节点。
const pg = findPinnedGroup();
const sc0 = findScrollContainer();
const anchored = pg ? (panel.previousElementSibling === pg) : (sc0 && sc0.firstChild === panel);
if (anchored) return; // 位置正确 → 稳态早退(避免每轮 schedule 重挂引发无限刷新循环)
// 位置不正确(夹在置顶分组内 / 兜底挂在最顶而分组已出现 / 误插容器外)→ 继续走下方重挂迁移
}
// 面板在文档中但不在滚动容器内(误插残留/容器被替换)→ 继续走下方重挂
}
const sc = findScrollContainer();
if (!sc) return; // 侧栏/滚动容器尚未就绪:暂不挂载,等下次 schedule 重试(避免误插容器外被钉死)
if (!panel) {
panel = document.createElement('div');
panel.id = 'dsFolderPanel';
panel.innerHTML = `
文件夹
`;
const headTitle = panel.querySelector('.dsHeadTitle');
const foldAll = () => { data.collapsed = !data.collapsed; saveData(); setCollapsedUI(); };
headTitle.addEventListener('click', foldAll);
headTitle.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); foldAll(); }
});
panel.querySelector('.dsNew').addEventListener('click', () => {
data.collapsed = false; // 新建时自动展开,方便立即看到新夹
setCollapsedUI(); // 先反映(含保存 collapsed)
onCreateFolder();
});
}
// 挂入/重挂入滚动容器(若面板此前被误插到别处则自动迁移,修复升级前的残留)
// 锚点 = 「置顶」分组容器之后:原生是 [置顶 sticky 头 → 置顶会话 → 分隔线],
// 若插在 sticky 头之后会夹在「置顶」标题与置顶会话之间,把分组切断。
const pg = findPinnedGroup();
if (pg) {
if (panel.previousElementSibling !== pg) pg.insertAdjacentElement('afterend', panel);
if (panel.style.paddingTop !== '') panel.style.paddingTop = ''; // 正常位置:对齐原生「分组头→首行」零间距节奏,无需为悬浮按钮预留
} else {
if (sc.firstChild !== panel) sc.insertBefore(panel, sc.firstChild); // 兜底:无置顶分组时插到列表最顶
if (panel.style.paddingTop !== '22px') panel.style.paddingTop = '22px'; // 顶部让开原生悬浮「多选」按钮带
}
setCollapsedUI(); // 挂载后再应用折叠态(原在创建块调用时面板未入 DOM,getElementById 找不到 → no-op,导致刷新后 data.collapsed 不生效)
renderFolders();
}
/* ---- 面板整体折叠态 UI(数据在 data.collapsed;纯 class 由 CSS 控制显示) ---- */
function setCollapsedUI() {
const panel = document.getElementById('dsFolderPanel');
if (!panel) return;
panel.classList.toggle('collapsed', !!data.collapsed);
const t = panel.querySelector('.dsHeadTitle');
if (t) t.title = data.collapsed ? '展开全部' : '折叠全部';
}
/* ---- 归档可见性:已收进文件夹的会话从原生历史列表隐藏(避免点它时官方滚回原位) ----
例外:「置顶」分组内的行不隐藏——置顶是用户的显式行为,若因归档进文件夹就整组消失,
等于置顶功能被脚本静默废掉(实测:脚本开启时置顶会话容器高度归 0)。 */
const archivedIds = () => Object.keys(data.links);
function syncArchiveVisibility() {
const all = document.querySelectorAll('a[href^="/a/chat/s/"]');
const set = new Set(archivedIds());
const pg = findPinnedGroup();
all.forEach((a) => {
const sid = sessionIdOf(a);
const isPinned = !!(pg && pg.contains(a));
if (!isPinned && sid && set.has(sid)) {
if (a.style.display !== 'none') a.style.display = 'none';
} else if (a.style.display === 'none') {
a.style.display = '';
}
});
}
/* ---- 原生「选择对话」(多选)模式互斥 ----
两者选择态互不相通(原生选择圈只给原生行、文件夹树无任何选择控件),
多选态下继续展示文件夹树没有意义 → 整区隐藏(display:none),退出后自动恢复。 */
function syncSelectModeLock() {
const panel = document.getElementById('dsFolderPanel');
if (!panel) return;
const listRoot = (pinnedGroupEl && pinnedGroupEl.isConnected && pinnedGroupEl.parentElement)
? pinnedGroupEl.parentElement : findScrollContainer();
const inSelectMode = !!(listRoot && listRoot.querySelector('.ds-checkbox'));
panel.classList.toggle('dsSelectModeHidden', inSelectMode);
}
/* ---- 原生「置顶」分组折叠 ----
取「置顶」分组容器内的 sticky 头作为可点击标题;折叠 = 隐藏容器内除标题外的所有兄弟。
用 document 级事件委托 + 我们自己的稳定类 .ds-pin-head,不依赖官网 hash 类名、不怕 React 重渲染;
折叠态每轮 schedule 重放(React 重建会重置 inline display / class)。 */
function findPinHeader() {
const pg = findPinnedGroup();
if (!pg) return null;
return [...pg.children].find((c) => {
const cs = getComputedStyle(c);
return cs.position === 'sticky' && (c.innerText || '').trim().startsWith('置顶');
}) || null;
}
function applyPinCollapse() {
const pg = findPinnedGroup();
const head = findPinHeader();
if (!pg || !head) return;
head.classList.add('ds-pin-head'); // 稳定类:样式与事件委托的挂钩
head.classList.toggle('dsPinCollapsed', pinGroupCollapsed);
[...pg.children].forEach((c) => {
if (c === head) return;
if (pinGroupCollapsed) {
if (c.style.display !== 'none') { c.style.display = 'none'; c.dataset.dsPinHidden = '1'; }
} else if (c.dataset.dsPinHidden === '1') {
c.style.display = ''; delete c.dataset.dsPinHidden; // 只还原自己藏过的,不动其它逻辑的 display
}
});
}
let pinClickHandler = null;
function bindPinClick() {
if (pinClickHandler) return;
pinClickHandler = (e) => {
const head = e.target && e.target.closest ? e.target.closest('.ds-pin-head') : null;
if (!head) return;
const pg = findPinnedGroup();
if (!pg || head.parentElement !== pg) return; // 只认当前置顶组的标题
if (document.querySelector('.ds-checkbox')) return; // 原生多选态下不响应折叠
pinGroupCollapsed = !pinGroupCollapsed;
GM_setValue(STORAGE_PIN_COLLAPSED, pinGroupCollapsed);
applyPinCollapse();
};
document.addEventListener('click', pinClickHandler, true);
}
function unbindPinClick() {
if (pinClickHandler) { document.removeEventListener('click', pinClickHandler, true); pinClickHandler = null; }
}
function resetPinCollapseUi() {
unbindPinClick();
const pg = findPinnedGroup();
if (pg) [...pg.children].forEach((c) => { if (c.dataset.dsPinHidden === '1') { c.style.display = ''; delete c.dataset.dsPinHidden; } });
document.querySelectorAll('.ds-pin-head').forEach((h) => h.classList.remove('ds-pin-head', 'dsPinCollapsed'));
}
/* ---------- 树形渲染 ---------- */
/* 文件夹行图标:折叠 = 空心文件夹;展开 = 打开文件夹(实心) */
const ICON_FOLDER_CLOSED = "";
const ICON_FOLDER_OPEN = "";
function renderFolders() {
const list = document.querySelector('#dsFolderPanel .dsList');
if (!list) return;
syncArchiveVisibility();
list.innerHTML = '';
// 树形:生成当前会话 on 状态快照
const currentOn = new Set();
{ const h = location.pathname.match(/\/s\/([^/]+)/); if (h) currentOn.add(h[1]); }
data.folders.forEach((f) => {
const sids = Object.keys(data.links).filter((k) => data.links[k] === f.id);
// 文件夹树行
const row = document.createElement('div');
row.className = 'dsItem';
row.innerHTML = `
${sids.length}
`;
row.querySelector('.dsName').textContent = f.name;
row.querySelector('.dsCaret').addEventListener('click', (ev) => {
ev.stopPropagation();
toggleExpanded(f.id); saveData(); renderFolders();
});
// 点文件夹行(排除箭头与操作钮):切换展开/折叠
row.addEventListener('click', (ev) => {
if (ev.target.dataset.act) return;
if (ev.target.classList.contains('dsCaret')) return;
toggleExpanded(f.id); saveData(); renderFolders();
});
row.querySelector('[data-act="rename"]').addEventListener('click', (ev) => { ev.stopPropagation(); onRenameFolder(f); });
row.querySelector('[data-act="del"]').addEventListener('click', (ev) => { ev.stopPropagation(); onDeleteFolder(f); });
list.appendChild(row);
// 展开时:内嵌该文件夹内会话行
if (sids.length && isExpanded(f.id)) {
const wrap = document.createElement('div');
wrap.className = 'dsFoldBody';
wrap.dataset.folder = f.id;
for (const sid of sids) {
const native = nativeNodeFor(sid);
if (!native) continue;
const on = currentOn.has(sid);
const cr = document.createElement('div');
cr.className = 'dsConvRow' + (on ? ' on' : '');
cr.innerHTML = ``;
const cvT = cr.querySelector('.dsConvTitle');
cvT.textContent = titleOf(native);
bindTitleTip(cvT, titleOf(native)); // 自绘 tooltip(官方质感),仅标题溢出时触发
cr.addEventListener('click', (e) => {
if (currentOn.has(sid)) return; // 已是当前会话:避免触发原生重载/回滚
const n = nativeNodeFor(sid);
if (n) n.click();
});
const out = document.createElement('button');
out.className = 'dsOut';
out.textContent = '移出';
out.addEventListener('click', (ev) => {
ev.stopPropagation();
delete data.links[sid];
saveData(); renderFolders();
});
cr.appendChild(out);
wrap.appendChild(cr);
}
list.appendChild(wrap);
}
});
if (data.folders.length === 0) {
const e = document.createElement('div');
e.className = 'dsEmpty';
e.textContent = '还没有文件夹,点「+ 新建」';
list.appendChild(e);
}
}
/* ---------- 自绘标题 tooltip(对齐官网 .ds-tooltip:bg #2c2c2e / #fff / 12px / 4px 8px / radius 10px;仅标题溢出时触发) ---------- */
let _dsConvTip = null;
function ensureConvTip() {
if (_dsConvTip && _dsConvTip.isConnected) return _dsConvTip;
_dsConvTip = document.createElement('div');
_dsConvTip.className = 'dsFolderTip';
document.body.appendChild(_dsConvTip);
return _dsConvTip;
}
function hideConvTip() { if (_dsConvTip) _dsConvTip.style.display = 'none'; }
function positionConvTip(anchor) {
const t = ensureConvTip();
const a = anchor.getBoundingClientRect();
t.style.display = 'block';
t.style.left = '0px'; t.style.top = '0px';
const tw = t.offsetWidth, th = t.offsetHeight;
let left = a.left + 8;
if (left + tw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - tw - 12);
let top = a.bottom + 6;
if (top + th > window.innerHeight - 8) top = a.top - th - 6;
t.style.left = left + 'px';
t.style.top = top + 'px';
}
function bindTitleTip(el, fullText) {
el.addEventListener('mouseenter', () => {
const s = getComputedStyle(el);
// 官网会话 tooltip 也仅当标题溢出(被省略号截断)时显示
if (s.scrollWidth > s.clientWidth || s.textOverflow === 'ellipsis') {
ensureConvTip().textContent = fullText;
positionConvTip(el);
}
});
el.addEventListener('mousemove', () => { if (_dsConvTip && _dsConvTip.style.display !== 'none') positionConvTip(el); });
el.addEventListener('mouseleave', hideConvTip);
}
/* ---- 树形与标签同步(共用):数据改动后的重建入口 ---- */
function resyncFolders() { refreshTags(); renderFolders(); }
/* ---------- 文件夹操作 ---------- */
function onCreateFolder() {
const name = prompt('新建文件夹名称:', '新建文件夹');
if (name === null) return;
const t = name.trim(); if (!t) return;
data.folders.push({ id: genId(), name: t });
saveData(); renderFolders();
}
function onRenameFolder(f) {
const name = prompt('重命名文件夹:', f.name);
if (name === null) return;
const t = name.trim(); if (!t) return;
f.name = t; saveData(); renderFolders();
}
function onDeleteFolder(f) {
if (!confirm(`删除文件夹「${f.name}」?其中的对话会回到「全部对话」。`)) return;
data.folders = data.folders.filter((x) => x.id !== f.id);
for (const k of Object.keys(data.links)) if (data.links[k] === f.id) delete data.links[k];
delete data.expanded[f.id];
saveData(); refreshTags(); renderFolders();
}
/* ---------- 标签 / 标题 ---------- */
function titleOf(a) {
const t = a.querySelector('div.c08e6e93');
return ((t ? t.textContent : a.textContent) || '').trim() || '未命名对话';
}
function refreshTags() {
const dark = isDark();
document.querySelectorAll('a[href^="/a/chat/s/"]').forEach((a) => {
const sid = sessionIdOf(a);
const fid = data.links[sid] || null;
const titleEl = a.querySelector('div.c08e6e93');
if (!titleEl) return;
const existing = titleEl.querySelector('.dsTag');
if (fid) {
const name = folderNameOf(fid);
const c = folderColor(fid);
if (!existing) {
const tag = document.createElement('span');
tag.className = 'dsTag'; tag.textContent = name;
tag.style.background = c + '30';
tag.style.color = dark ? c : shadeHex(c, -0.48);
titleEl.appendChild(tag);
} else if (existing.textContent !== name || existing.dataset.fid !== fid) {
existing.textContent = name; existing.dataset.fid = fid;
existing.style.background = c + '30';
existing.style.color = dark ? c : shadeHex(c, -0.48);
}
} else if (existing) { existing.remove(); }
});
}
/* ---------- 官方三点菜单注入 ---------- */
function escapeHtml(s) {
return (s || '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
// 记录当前打开菜单所属会话(关闭时不记录)。按下/点击三点按钮即视为打开官方菜单:
// React portal 可复用浮层容器会在重现菜单时,若该项恰在指针正下方而指针没动,也会合成 mouseenter。
// 这里分别记住「按下时刻」与「最近一次真实指针位移」,供 openOnHover 判定是否为“伪悬停”。
document.addEventListener('pointerdown', (e) => {
if (!folderManagerEnabled) return;
if (e.target.closest('#dsFolderPop, [data-ds-move]')) return; // 我们自己的浮层/项不算打开官方菜单
lastMenuOpenAt = Date.now();
}, true);
document.addEventListener('click', (e) => {
if (!folderManagerEnabled) return;
const btn = e.target.closest('[class*="ds-button"]');
if (!btn) return;
const link = btn.closest('a[href^="/a/chat/s/"]');
if (!link) return;
const sid = sessionIdOf(link);
if (sid) fCurrentMenuSid = sid;
}, true);
function injectMoveToFolder(menu) {
if (menu.querySelector('[data-ds-move]')) return;
const item = document.createElement('div');
item.className = 'ds-dropdown-menu-option ds-dropdown-menu-option--none';
item.setAttribute('role', 'menuitem');
item.dataset.dsMove = '1';
item.innerHTML = `
`;
// 鼠标从官方菜单项滑到本项后,官方项的高亮态残留修复
const clearNativeHighlight = () => {
menu.querySelectorAll('.ds-dropdown-menu-option').forEach((o) => {
if (o === item) return;
o.removeAttribute('data-highlighted');
if (o.getAttribute('data-state') === 'active') o.setAttribute('data-state', '');
o.removeAttribute('aria-selected');
[...o.classList].forEach((c) => { if (/highlight|active/i.test(c)) o.classList.remove(c); });
});
};
item.addEventListener('mouseenter', clearNativeHighlight);
item.addEventListener('pointerenter', clearNativeHighlight);
// 悬停即展开次级菜单(仿 Windows 右键二级菜单)
const openOnHover = () => {
if (document.getElementById('dsFolderPop')) return;
// 防「伪悬停」误展开:点击三点按钮重现当前菜单时,若该项恰在指针正下方而指针没动,
// 浏览器仍会合成一个 mouseenter。判定——菜单刚被打开(pointerdown 距今 <300ms)且
// 自该打开后没有任何真实指针位移(lastRealMoveAt 停在更早) —— 视为伪事件,先不展开,
// 等用户真正把光标移入该项才 `mouseenter`/`pointerenter`(届时已有新位移)自然打开。v4.7.0
if (Date.now() - lastMenuOpenAt < 300 && lastRealMoveAt < lastMenuOpenAt) return;
cancelClosePopup();
if (fCurrentMenuSid) openFolderPopup(fCurrentMenuSid, item);
};
item.addEventListener('mouseenter', openOnHover);
item.addEventListener('pointerenter', openOnHover);
item.addEventListener('mouseleave', scheduleClosePopup);
item.addEventListener('mouseenter', cancelClosePopup);
item.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
if (document.getElementById('dsFolderPop')) return;
if (fCurrentMenuSid) openFolderPopup(fCurrentMenuSid, item);
});
menu.appendChild(item);
}
// 悬停意图计时:离开浮层/锚点后稍候再关,避免穿梭 4px 间隙闪烁
let dsPopupCloseTimer = null;
function scheduleClosePopup() { clearTimeout(dsPopupCloseTimer); dsPopupCloseTimer = setTimeout(closeFolderPopup, 220); }
function cancelClosePopup() { clearTimeout(dsPopupCloseTimer); dsPopupCloseTimer = null; }
function closeFolderPopup() {
clearTimeout(dsPopupCloseTimer); dsPopupCloseTimer = null;
const p = document.getElementById('dsFolderPop'); if (p) p.remove();
}
function openFolderPopup(sid, anchor) {
closeFolderPopup();
const dark = isDark();
const pop = document.createElement('div');
pop.id = 'dsFolderPop';
pop.style.cssText = `position:fixed;z-index:2000;background:${dark ? '#2a2e3a' : '#ffffff'};
border:1px solid var(--ds-border);border-radius:10px;padding:5px;min-width:175px;
box-shadow:0 10px 30px rgba(0,0,0,.4);font-size:13.5px;color:var(--ds-text);
max-height:calc(100vh - 16px);overflow:auto;`;
let html = '';
for (const f of data.folders) {
html += `
${escapeHtml(f.name)}`;
}
html += `
+ 新建文件夹…
移出文件夹(未分类)`;
pop.innerHTML = html;
document.body.appendChild(pop);
// 级联定位:锚点右侧展开,放不下翻左侧,垂直对齐夹紧在视口内
const r = anchor.getBoundingClientRect();
const pw = pop.offsetWidth, ph = pop.offsetHeight, gap = 4;
let left = r.right + gap;
if (left + pw > window.innerWidth - 8) { left = r.left - pw - gap; if (left < 8) left = 8; }
let top = r.top - 2;
top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
pop.style.left = left + 'px';
pop.style.top = top + 'px';
pop.addEventListener('mouseenter', cancelClosePopup);
pop.addEventListener('mouseleave', scheduleClosePopup);
pop.addEventListener('click', (ev) => {
const it = ev.target.closest('.dsmpItem');
if (!it) return;
let fid = it.dataset.fid;
if (fid === '__new__') {
const name = prompt('新建文件夹名称:', '新建文件夹');
if (name === null) return;
const t = name.trim(); if (!t) return;
fid = genId(); data.folders.push({ id: fid, name: t });
} else if (fid === '__none__') {
delete data.links[sid];
} else {
data.links[sid] = fid;
}
saveData();
// 收敛「移动」:仅移除我们自己的浮层,绝不把祖级官方浮层容器 .ds-floating-container 设 display:none。
// 原因:官方该容器跨会话复用(React portal 只在首开时创建、之后复用),若我们用内联 none 藏死它,
// 之后再次点 ⋯ 菜单只会渲染进这个永久隐藏的容器 → 表现为“菜单点不开/无反应”。
// 官方菜单的关闭由 DeepSeek 自身的点击/ESC 语义处理,这里不越权接管。
closeFolderPopup();
resyncFolders();
});
setTimeout(() => document.addEventListener('click', closeFolderPopup, { once: true }), 0);
}
function injectMenus() {
if (!folderManagerEnabled) return;
document.querySelectorAll('.ds-dropdown-menu[role="menu"]').forEach((menu) => {
const t = menu.textContent || '';
if (!/重命名/.test(t) || !/删除/.test(t)) return; // 仅会话菜单
injectMoveToFolder(menu);
});
}
/* ---------- 由外层驱动的刷新(取代原自持 observer;v0.9.2 导航守卫防自循环) ---------- */
let folderPending = null;
let folderLastSid = (location.pathname.match(/\/s\/([^/]+)/) || [])[1] || null; // 高亮/重绘守卫
function schedule() {
if (!folderManagerEnabled) return;
if (folderPending) return;
folderPending = setTimeout(() => {
folderPending = null;
ensurePanel();
refreshTags();
injectMenus();
// 导航切换后重绘树(仅当 url 会话变化才做,避免 observer 自激循环)
const cur = (location.pathname.match(/\/s\/([^/]+)/) || [])[1] || null;
if (cur !== folderLastSid) { folderLastSid = cur; renderFolders(); }
syncArchiveVisibility(); // 外部新增会话行也要按归档立即隐藏(仅改 display,不引循环)
syncSelectModeLock(); // 原生多选态下禁用文件夹树交互(模式互斥)
if (pinGroupCollapsible) applyPinCollapse(); // 重放「置顶」分组折叠态(React 重建会重置;仅开关开启时)
}, 120);
}
function cancelSchedule() { if (folderPending) { clearTimeout(folderPending); folderPending = null; } }
/* ---------- on / off(外层开关驱动) ---------- */
function on() {
injectCss();
applyTheme();
if (pinGroupCollapsible) bindPinClick(); // 置顶折叠为 opt-in,仅开关开启时接管点击
schedule();
}
function off() {
cancelSchedule();
resetPinCollapseUi(); // 解绑「置顶」折叠点击并还原被折叠隐藏的原生节点
fCurrentMenuSid = null;
closeFolderPopup();
if (_dsConvTip) { _dsConvTip.remove(); _dsConvTip = null; } // 清掉自绘 tooltip 残留节点
// 复位归档隐藏:把 syncArchiveVisibility 藏起来的官方会话行全部还原(不越权动原生结构)
document.querySelectorAll('a[href^="/a/chat/s/"]').forEach((a) => { a.style.display = ''; });
document.querySelectorAll('[data-ds-move]').forEach((el) => el.remove());
document.querySelectorAll('.dsTag').forEach((el) => el.remove());
const panel = document.getElementById('dsFolderPanel'); if (panel) panel.remove();
removeCss();
// 移除注入的 --ds-* CSS 变量(复原官网原生配色源)
const rs = document.documentElement.style;
['--ds-text','--ds-sub','--ds-hover','--ds-active-bg','--ds-accent','--ds-divider','--ds-border']
.forEach((k) => rs.removeProperty(k));
}
function refreshData() { data = loadData(); renderFolders(); refreshTags(); }
/* 置顶折叠能力开关(由设置面板驱动):开启则绑定点击并应用折叠态;关闭则解绑并还原被折叠节点。 */
function setPinCollapsible(enabled) {
if (!folderManagerEnabled) return; // 文件夹模块未启用时无需处理
if (enabled) { bindPinClick(); applyPinCollapse(); }
else { resetPinCollapseUi(); }
}
return { on, off, schedule, refreshData, setPinCollapsible, isDark };
})();
// hold the ref so disabled switching can re-load stored folder list later
const folderEnabledChanged = (nowEnabled) => {
if (nowEnabled) { folderUnit.on(); showToast('对话文件夹管理已开启'); }
else { folderUnit.off(); showToast('对话文件夹管理已关闭'); }
// 子开关「置顶分组可折叠」的置灰态由 refreshPanelDeps() 即时重算;
// 不再「关面板 + 300ms 重开」——那会把导航打回第一页、并打断正在操作的输入焦点。
};
// ==================== 对话导出(Markdown / JSON) ====================
// 对话 → Markdown / JSON 的完整导出链路。
//
// 链路:提取(页面 DOM → 语义 DOM)→ 规范化(公式/代码/列表/表格/噪音)
// → 规则驱动转换(13 条规则,深度优先)→ 模板装饰 → 收尾归一 → Blob 下载
// JSON 走同一条提取与转换管线,只是把结果装进「一条消息一条记录」的结构,不再套模板。
//
// 若干刻意的设计取舍:
// 1. 自有属性统一带 data-ds-md-* 前缀,不与页面既有属性冲突;
// 2. 只依赖页面自身的 DOM 结构,不依赖任何注入式的运行时状态;
// 3. 转换引擎为自研规则表,不引入任何第三方 Markdown 库。
const mdExportUnit = (() => {
// ---------- 选择器(取真实页面实测结构) ----------
const SEL = {
MESSAGE: '.ds-message',
AI_CONTENT: '.ds-markdown', // AI 答案根;用户消息不含此元素 → 角色判据
THINK_CONTENT: '.ds-think-content', // 思考过程正文(语义类名)
COLLAPSIBLE: '.ds-collapsible-text', // 用户文本容器(语义类名)
CODE_BLOCK: '.md-code-block',
CODE_BANNER: '.md-code-block-banner-wrap', // 语言文案所在("语言\n复制\n下载\n展开")
KATEX_DISPLAY: '.katex-display',
KATEX: '.katex',
SCROLL_AREA: '.ds-scroll-area', // 表格外层滚动壳,需脱壳
VIRTUAL_LIST: '.ds-virtual-list-items', // 虚拟列表容器(存在即说明对话被虚拟化)
VIRTUAL_ITEM: '[data-virtual-list-item-key]', // 虚拟列表项(消息的稳定键来源)
};
const ATTR = {
NODE: 'data-ds-md-node', // 节点类型标记(同时作为"自有属性"参与 stripNoise 保护判定)
LEVEL: 'data-ds-md-level',
SECTION: 'data-ds-md-section', // question | answer —— 模板装饰的定位锚点
LANG: 'data-ds-md-lang',
TEX: 'data-ds-md-tex',
MATH: 'data-ds-md-math',
};
const OWN_ATTR_PREFIX = 'data-ds-md-';
// ---------- 基础工具 ----------
const isEl = (n) => !!n && n.nodeType === Node.ELEMENT_NODE;
const tag = (n) => (isEl(n) ? String(n.tagName || '').toLowerCase() : '');
const lf = (s) => String(s).replace(/\r\n?/g, '\n'); // CRLF → LF
const stripNl = (s) => lf(s).replace(/^\n+/, '').replace(/\n+$/, ''); // 去首尾换行
const maxRun = (s, ch) => { let best = 0, cur = 0; for (const c of s) { if (c === ch) { cur++; if (cur > best) best = cur; } else cur = 0; } return best; };
// 反引号自适应:比内容中最长反引号串多 1(至少 min)。代码块内含 ``` 时否则必然被提前闭合。
const fenceOf = (s, min) => '`'.repeat(Math.max(min, maxRun(s, '`') + 1));
// 行内代码:前后含空格时补空格包裹(CommonMark 规定)
const inlineCodeWrap = (s) => { const f = fenceOf(s, 1); return f + ((s.startsWith(' ') || s.endsWith(' ')) ? ' ' + s + ' ' : s) + f; };
// 表格单元格:换行折叠为空格 + 转义竖线
const cellText = (s) => lf(s).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim();
const escLinkText = (s) => String(s).replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]');
const isInside = (el, tags) => { const set = new Set(tags.map(t => t.toLowerCase())); let p = el.parentElement; while (p) { if (set.has(tag(p))) return true; p = p.parentElement; } return false; };
const headingLevel = (t) => { const m = /^h([1-6])$/.exec(t); return m ? Number(m[1]) : 0; };
// ---------- 转换引擎:13 条规则(顺序敏感) ----------
// 规则表顺序决定了嵌套处理:先"吞内容"的块级,再行内。首个命中即用。
function findCodeChild(el) {
return [...el.children].find(c => tag(c) === 'code') || el.querySelector('code') || null;
}
const RULE_CODE_BLOCK = {
name: 'code-block',
filter: (el) => tag(el) === 'pre' && !!findCodeChild(el),
replacement(el) {
const code = findCodeChild(el);
if (!code) return '';
const lang = String(
code.getAttribute(ATTR.LANG) ?? el.getAttribute(ATTR.LANG) ?? ''
).trim();
const body = stripNl(lf(code.textContent ?? '')).replace(/\n+$/g, '');
const f = fenceOf(body, 3);
return f + lang + '\n' + body + '\n' + f + '\n\n';
},
};
const RULE_INLINE_CODE = {
name: 'inline-code',
filter: (el) => tag(el) === 'code' && !isInside(el, ['pre']),
replacement(el) {
const t = el.textContent ?? '';
return t.trim() ? inlineCodeWrap(t) : '';
},
};
const RULE_MATH = {
name: 'math',
filter: (el) => {
const mt = el.getAttribute(ATTR.MATH);
const tex = el.getAttribute(ATTR.TEX);
return (mt === 'inline' || mt === 'block') && typeof tex === 'string' && tex.trim().length > 0;
},
replacement(el) {
const mt = el.getAttribute(ATTR.MATH);
const tex = lf(el.getAttribute(ATTR.TEX) ?? '').trim();
if (!tex) return '';
return mt === 'inline' ? '$' + tex.replace(/\n+/g, ' ') + '$' : '$$\n' + tex + '\n$$\n\n';
},
};
const RULE_TABLE = {
name: 'table',
filter: (el) => tag(el) === 'table',
replacement(el, _content, ctx) {
const rows = [...el.querySelectorAll('tr')];
if (!rows.length) return '';
const cells = (row) => [...row.children].filter(c => tag(c) === 'td' || tag(c) === 'th');
// 表头:首个含 th 的行;都没有则首行充作表头
let header = rows.find(r => cells(r).some(c => tag(c) === 'th')) || null;
let body;
if (header) body = rows.filter(r => r !== header);
else { header = rows[0]; body = rows.slice(1); }
if (!header) return '';
const conv = (row) => cells(row).map(c => cellText(stripNl(ctx.api.children(c, ctx.state))));
const hc = conv(header);
const bc = body.map(conv);
const cols = Math.max(1, hc.length, ...bc.map(r => r.length));
const pad = (arr) => '| ' + Array.from({ length: cols }, (_, i) => arr[i] ?? '').join(' | ') + ' |';
const sep = pad(Array.from({ length: cols }, () => '---'));
return pad(hc) + '\n' + sep + (bc.length ? '\n' + bc.map(pad).join('\n') : '') + '\n\n';
},
};
const RULE_HEADING = {
name: 'heading',
filter: (el) => headingLevel(tag(el)) > 0,
replacement(el, content) {
const lv = headingLevel(tag(el));
const t = stripNl(content).trim();
return t ? '#'.repeat(lv) + ' ' + t + '\n\n' : '';
},
};
const RULE_HR = {
name: 'hr',
filter: (el) => tag(el) === 'hr',
replacement: () => '---\n\n',
};
const RULE_BLOCKQUOTE = {
name: 'blockquote',
filter: (el) => tag(el) === 'blockquote',
replacement(_el, content) {
const s = stripNl(content).trim();
if (!s) return '';
return lf(s).split('\n').map(l => (l.trim() ? '> ' + l : '>')).join('\n') + '\n\n';
},
};
const RULE_LIST = {
name: 'list',
filter: (el) => tag(el) === 'ul' || tag(el) === 'ol' || tag(el) === 'li',
replacement(el, _content, ctx) {
const t = tag(el);
// 容器 ul / ol:整体缩进交给子 li 处理,这里只负责外层换行
if (t === 'ul' || t === 'ol') {
const inner = stripNl(ctx.api.children(el, {
listDepth: ctx.state.listDepth + 1,
listIndent: ctx.state.listIndent,
})).replace(/\n+$/g, '');
if (!inner.trim()) return '';
return (tag(el.parentElement) === 'li' ? '\n' : '') + inner + '\n\n';
}
// li:marker + 续行缩进对齐
const parent = el.parentElement;
let marker;
if (tag(parent) === 'ol') {
const raw = String(parent.getAttribute('start') ?? '').trim();
const n = Number.parseInt(raw, 10);
const base = Number.isFinite(n) ? n : 1;
const idx = [...parent.children].filter(c => tag(c) === 'li').indexOf(el);
marker = (base + (idx >= 0 ? idx : 0)) + '.';
} else {
marker = '-';
}
// 每层缩进 2 空格;续行缩进 = 缩进 + marker 长度 + 1(与首行文字左对齐)
const indent = ctx.state.listIndent || ' '.repeat(Math.max(0, ctx.state.listDepth - 1));
const contIndent = indent + ' '.repeat(marker.length + 1);
const inner = stripNl(ctx.api.children(el, {
listDepth: ctx.state.listDepth,
listIndent: contIndent,
})).trim();
if (!inner) return '';
const lines = lf(inner).split('\n');
const first = lines[0] ?? '';
const nested = first.startsWith(contIndent) && /^([-*+]|\d+\.)\s+/.test(first.trimStart());
const out = nested ? [indent + marker] : [indent + marker + ' ' + first.trim()];
for (let i = nested ? 0 : 1; i < lines.length; i++) {
const l = lines[i] ?? '';
if (!l.trim()) continue;
out.push(l.startsWith(contIndent) ? l : contIndent + l.trim());
}
return out.join('\n') + '\n';
},
};
const RULE_PARAGRAPH = {
name: 'paragraph',
filter: (el) => tag(el) === 'p',
replacement(_el, content, ctx) {
const t = stripNl(content).trim();
if (!t) return '';
return ctx.state.listDepth > 0 ? t + '\n' : t + '\n\n'; // 列表内单换行,列表外双换行
},
};
const RULE_LINK = {
name: 'link',
filter: (el) => tag(el) === 'a',
replacement(el, content) {
const href = String(el.getAttribute('href') ?? '').trim();
const text = stripNl(content).trim() || String(el.textContent ?? '').trim() || href;
return href ? '[' + escLinkText(text) + '](' + href + ')' : text;
},
};
const RULE_IMAGE = {
name: 'image',
filter: (el) => tag(el) === 'img',
replacement(el) {
const src = String(el.getAttribute('src') ?? '').trim();
if (!/^https?:\/\//i.test(src)) return ''; // 跳过 data:/blob: 等内联大图
const alt = String(el.getAttribute('alt') ?? '').trim();
return '![' + alt.replace(/[\[\]]/g, '') + '](' + src + ')';
},
};
const RULE_STRONG = {
name: 'strong',
filter: (el) => tag(el) === 'strong' || tag(el) === 'b',
replacement(_el, content) {
const t = stripNl(content).trim();
return t ? '**' + t + '**' : '';
},
};
const RULE_EMPHASIS = {
name: 'emphasis',
filter: (el) => tag(el) === 'em' || tag(el) === 'i',
replacement(_el, content) {
const t = stripNl(content).trim();
return t ? '*' + t + '*' : '';
},
};
const RULES = [
RULE_CODE_BLOCK, RULE_INLINE_CODE, RULE_MATH, RULE_TABLE,
RULE_HEADING, RULE_HR, RULE_BLOCKQUOTE, RULE_LIST,
RULE_PARAGRAPH, RULE_LINK, RULE_IMAGE, RULE_STRONG, RULE_EMPHASIS,
];
const INIT_STATE = { listDepth: 0, listIndent: '' };
function convertNode(api, n, state) {
if (n.nodeType === Node.TEXT_NODE) {
const v = n.nodeValue ?? '';
// HTML 排版换行(含换行且 trim 后为空)必须丢弃,否则 Markdown 里会留下多余空格
if (/[^\S\r\n]*[\r\n][^\S\r\n]*/.test(v) && v.trim() === '') return '';
return lf(v).replace(/\n+/g, ' ').replace(/[\t\f\v]+/g, ' ');
}
if (!isEl(n)) {
if (n.nodeType === Node.DOCUMENT_NODE || n.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return api.children(n, state);
}
return '';
}
const t = tag(n);
if (t === 'script' || t === 'style') return '';
if (t === 'br') return '\n';
const ctx = { api, state };
const rule = RULES.find(r => { try { return r.filter(n, ctx); } catch (e) { return false; } });
if (!rule) return api.children(n, state); // 无规则 → 透明穿透
try {
return rule.replacement(n, api.children(n, state), ctx);
} catch (e) {
return api.children(n, state);
}
}
const converter = {
children(node, state) {
let out = '';
node.childNodes.forEach(n => { out += convertNode(this, n, state); });
return out;
},
convert(root) {
const raw = convertNode(this, root, Object.assign({}, INIT_STATE));
// 收尾:CRLF→LF → 三个及以上换行压成两个 → 去首尾空白
return lf(raw).replace(/\n{3,}/g, '\n\n').trim();
},
};
// ---------- 规范化:把 DeepSeek 的 DOM 洗成引擎认识的语义 DOM ----------
// 表格外层滚动壳脱壳(.ds-scroll-area 会架空 table 的父子关系)
function unwrapTableShell(root) {
root.querySelectorAll(SEL.SCROLL_AREA).forEach(shell => {
const tb = shell.querySelector('table');
if (tb && shell.parentNode) shell.parentNode.replaceChild(tb, shell);
});
}
// KaTeX → data-ds-md-tex / data-ds-md-math(从 MathML 的 annotation 里取回原始 LaTeX 源码)
// 引擎只需认自有属性,与官网 KaTeX 内部结构解耦。
function normalizeMath(root) {
const mark = (el, tex, isDisplay) => {
const span = document.createElement('span');
span.setAttribute(ATTR.NODE, 'math');
span.setAttribute(ATTR.TEX, tex);
span.setAttribute(ATTR.MATH, isDisplay ? 'block' : 'inline');
try { el.replaceWith(span); } catch (e) { /* 已脱离文档则忽略 */ }
};
root.querySelectorAll(SEL.KATEX_DISPLAY).forEach(el => {
const ann = el.querySelector('annotation[encoding="application/x-tex"]');
const tex = String((ann && ann.textContent) || '').trim();
if (tex) mark(el, tex, true);
});
root.querySelectorAll(SEL.KATEX).forEach(el => {
if (el.closest(SEL.KATEX_DISPLAY)) return;
const ann = el.querySelector('annotation[encoding="application/x-tex"]');
let tex = String((ann && ann.textContent) || '').trim();
if (!tex) return;
tex = tex.replace(/\r\n?|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
mark(el, tex, false);
});
}
// 代码块:提取语言 → 剥离 banner/装饰 svg → 合成 包裹
// 真实 DOM 的 pre 只有语法高亮 span,没有 ,需要自己合成。
const CODE_OP_LABEL = /^(复制|下载|展开|收起|编辑|运行|预览|copy|download|expand|collapse|edit|run)$/i;
function normalizeCodeBlocks(root) {
root.querySelectorAll(SEL.CODE_BLOCK).forEach(block => {
const banner = block.querySelector(SEL.CODE_BANNER);
let lang = '';
if (banner) {
// 双 Tab(代码/图表)→ mermaid
const tabs = [...banner.querySelectorAll('div[role="tab"], .ds-segmented-button')]
.map(e => String(e.textContent || '').trim().toLowerCase())
.filter(Boolean);
if (tabs.includes('图表') && tabs.includes('代码')) lang = 'mermaid';
// 否则取 banner 内首个非操作按钮的短文本(banner 文案 = "语言\n复制\n下载\n展开")
if (!lang) {
for (const sp of banner.querySelectorAll('span')) {
const s = String(sp.textContent || '').trim();
if (!s || s.length > 40 || CODE_OP_LABEL.test(s)) continue;
lang = s.split(/\s+/)[0].trim().toLowerCase();
if (lang) break;
}
}
banner.remove();
}
[...block.children].forEach(c => { if (tag(c) === 'svg') c.remove(); });
const pre = block.querySelector('pre');
if (!pre) return;
[...pre.children].forEach(c => { if (tag(c) === 'svg') c.remove(); });
let code = pre.querySelector('code');
if (!code) {
code = document.createElement('code');
code.innerHTML = pre.innerHTML;
pre.innerHTML = '';
pre.appendChild(code);
}
if (lang) code.setAttribute(ATTR.LANG, lang);
});
}
// 只保留 start(其余属性归零),- 去掉 value
function normalizeLists(root) {
root.querySelectorAll('ol').forEach(ol => {
let start = 1;
const n = Number.parseInt(String(ol.getAttribute('start') ?? '').trim(), 10);
if (Number.isFinite(n)) start = n;
[...ol.attributes].forEach(a => ol.removeAttribute(a.name));
ol.setAttribute('start', String(start));
});
root.querySelectorAll('li').forEach(li => li.removeAttribute('value'));
}
// 纯文本 div →
(用户消息体没有
,不补则得不到段落换行)
// 注意排除自有标记元素:section 容器必须保持
,否则模板装饰的 insertBefore 会产出
// 非法的 嵌套(浏览器会重排,装饰失效)。
const BLOCKISH = 'table, pre, ul, ol, blockquote, hr, h1, h2, h3, h4, h5, h6, .md-code-block';
function isSimpleTextDiv(el) {
if (tag(el) !== 'div') return false;
try {
if ([...el.attributes].some(a => a.name.startsWith(OWN_ATTR_PREFIX))) return false;
} catch (e) { /* 忽略 */ }
try {
if (el.querySelector(BLOCKISH)) return false;
if (el.querySelector('div')) return false;
return !!String(el.textContent || '').trim();
} catch (e) { return false; }
}
function divToParagraph(root) {
// 自内向外替换,避免上层判定被下层替换结果影响
[...root.querySelectorAll('div')].reverse().forEach(d => {
if (!isSimpleTextDiv(d)) return;
const p = document.createElement('p');
p.innerHTML = d.innerHTML;
try { d.replaceWith(p); } catch (e) { /* 忽略 */ }
});
}
// 行内图标标记(引用角标 / 联网标识)
// 实测结构:
// - ← 隐藏占位,撑宽度
// …14×14 svg… ← 悬浮图标层
//
// 判据用「样式特征」而非哈希类名:含 absolute 或 opacity:0 的装饰子层,且剥掉装饰后仅剩
// 极短编号(或空)。这样官网改版换类名也不失效。
const ABS_RE = /position\s*:\s*absolute/i;
const ZERO_RE = /opacity\s*:\s*0(?:\.0+)?/i;
const styleOf = (el) => String((el && el.getAttribute && el.getAttribute('style')) || '');
function isCitationBadge(el) {
const t = tag(el);
if (t !== 'span' && t !== 'a') return false;
if (isInside(el, ['pre', 'code', 'katex'])) return false;
const kids = [...el.children];
const hasAbs = kids.some(c => ABS_RE.test(styleOf(c)));
const hasZero = kids.some(c => ZERO_RE.test(styleOf(c)));
if (!hasAbs && !hasZero) return false;
// 剥掉装饰子层后,剩余文本必须为空或极短的引用编号
const clone = el.cloneNode(true);
clone.querySelectorAll('svg').forEach(s => s.remove());
clone.querySelectorAll('*').forEach(c => {
const st = styleOf(c);
if (ABS_RE.test(st) || ZERO_RE.test(st)) { try { c.remove(); } catch (e) { /* 忽略 */ } }
});
const rest = String(clone.textContent || '').replace(/[\s\u200b\ufeff\u200c\u200d]+/g, '');
return rest.length <= 3;
}
// 噪音清理:引用角标 → 按钮/图标 → 装饰 svg → 空 span 解包
// 注意顺序:角标判据依赖「绝对定位层 + 装饰 svg」,必须先于删 svg 执行。
function stripNoise(root) {
[...root.querySelectorAll('a, span')].forEach(n => {
if (!n.parentNode) return;
if (!isCitationBadge(n)) return;
try { n.remove(); } catch (e) { /* 忽略 */ }
});
// 角标剥离后可能留下空链接(只剩 href)→ 一并清掉
[...root.querySelectorAll('a')].forEach(n => {
if (!n.parentNode) return;
if (String(n.textContent || '').trim()) return;
if (n.querySelector('img')) return;
try { n.remove(); } catch (e) { /* 忽略 */ }
});
root.querySelectorAll('button, [role="button"], .ds-icon').forEach(n => {
try { n.remove(); } catch (e) { /* 忽略 */ }
});
// 数学已抽成自有 span,其余 svg 均为装饰
root.querySelectorAll('svg').forEach(n => {
if (isInside(n, ['katex'])) return;
try { n.remove(); } catch (e) { /* 忽略 */ }
});
[...root.querySelectorAll('span')].forEach(n => {
if (!n.parentNode) return;
if (n.closest('.katex, .katex-display')) return;
const attrs = [...n.attributes].map(a => a.name);
if (attrs.some(a => a.startsWith(OWN_ATTR_PREFIX))) return; // 自有标记不展开
if (attrs.includes('class')) return; // 带 class 的 span 保守保留
if (String(n.textContent || '').trim()) return; // 有文字则保留
try { // 无 class 无属性的空 span → 解包
while (n.firstChild) n.parentNode.insertBefore(n.firstChild, n);
n.remove();
} catch (e) { /* 忽略 */ }
});
}
// 八步规范化(各自容错,单点失败不阻断其余)
function normalizeContent(doc, root, opts) {
const steps = [
() => unwrapTableShell(root),
() => normalizeMath(root),
() => normalizeCodeBlocks(root),
() => normalizeLists(root),
() => divToParagraph(root),
() => stripNoise(root),
];
steps.forEach(fn => { try { fn(); } catch (e) { console.warn('[deepseektool] 导出规范化步骤失败:', e); } });
}
// ---------- 提取:DeepSeek DOM → 语义消息 ----------
// AI 答案根:取消息内第一个「不在思考区里」的 .ds-markdown。
// 陷阱:思考过程正文自身也是 .ds-markdown,且在文档顺序上位于答案之前,
// 直接 querySelector('.ds-markdown') 会错拿到思考内容。
// 用显式过滤(`closest('.ds-think-content')`)而非 CSS `:not(.a .b)`,兼容性更好。
function resolveAiContent(msgEl) {
let all = [];
try { all = [...msgEl.querySelectorAll(SEL.AI_CONTENT)]; } catch (e) { return null; }
for (const el of all) {
let inThink = false;
try { inThink = !!el.closest(SEL.THINK_CONTENT); } catch (e) { inThink = false; }
if (!inThink) return el;
}
return null;
}
// 思考过程正文:直接取 .ds-think-content(语义类名,实测稳定)。
// 注意结构陷阱:思考正文并不在折叠条的兄弟层,而是挂在折叠条容器(._245c867)的祖父层
// (._74c0879)下 —— 一条消息可能有多个 .ds-think-content(分片流式输出),需全部收集。
// 另需排除「已阅读 N 个网页」这类联网标识(同为 .ds-think-content,但不是思考过程)。
const WEB_READ_RE = /^已阅读\s*\d+\s*个网页$/;
function collectReasoningNodes(msgEl) {
let nodes = [];
try { nodes = [...msgEl.querySelectorAll(SEL.THINK_CONTENT)]; } catch (e) { return []; }
return nodes.filter(n => {
const txt = String(n.textContent || '').trim();
if (!txt) return false;
return !WEB_READ_RE.test(txt);
});
}
// 用户文本根:优先语义类名 .ds-collapsible-text,回退「直接子级中文本最长且无 AI 内容/媒体/按钮」
function resolveUserRoot(msgEl) {
const collapsible = msgEl.querySelector(SEL.COLLAPSIBLE);
if (collapsible) return collapsible;
let best = null, bestLen = 0;
[...msgEl.children].forEach(c => {
if (!isEl(c)) return;
try {
if ([...c.querySelectorAll(SEL.AI_CONTENT)].some(x => !x.closest(SEL.THINK_CONTENT))) return;
} catch (e) { /* 忽略 */ }
if (c.querySelector('img, video, canvas, figure, button, [role="button"]')) return;
const len = String(c.textContent || '').trim().length;
if (len > bestLen) { bestLen = len; best = c; }
});
return best;
}
// 只保留「思考体 + 答案体」,其余(折叠条 / 操作栏 / 引用来源 / 附件缩略图 / 头像)一律丢弃。
// 这比逐个枚举待删选择器稳健得多 —— 官网加任何新的装饰层都不会污染输出。
// 产出的是**语义 DOM**(不做字符串转换),以便模板装饰阶段在其上插入 h1/p/blockquote。
//
// 拆成「来源 → 语义 DOM」两步,是因为长对话必须靠快照导出(虚拟列表会把滚过的消息卸载):
// 快照存的是原始素材(HTML 串),导出时用同一个函数还原,保证两条路径产出一致。
function wrapFromSource(role, src, opts) {
if (!src) return null;
const wrap = document.createElement('div');
const fill = (host, html) => {
if (!html) return;
const tmp = document.createElement('div');
tmp.innerHTML = html;
while (tmp.firstChild) host.appendChild(tmp.firstChild);
};
if (role === 'assistant') {
if (opts.includeReasoning && src.reasoningHTML) {
const bq = document.createElement('blockquote');
fill(bq, src.reasoningHTML);
if (bq.firstChild) wrap.appendChild(bq);
}
if (!src.answerHTML) return null;
const sec = document.createElement('div');
sec.setAttribute(ATTR.SECTION, 'answer');
fill(sec, src.answerHTML);
wrap.appendChild(sec);
} else {
if (!src.questionHTML) return null;
const sec = document.createElement('div');
sec.setAttribute(ATTR.SECTION, 'question');
fill(sec, src.questionHTML);
wrap.appendChild(sec);
}
normalizeContent(document, wrap, opts);
return wrap;
}
const toMarkdown = (wrap) => {
const md = converter.convert(wrap);
return md.trim() ? md.trim() : null;
};
// ==================== 内容模板 ====================
// 模板清单与装饰实现。
// 装饰产出语义 DOM(h1/h2/p/blockquote),正好喂给上面的转换引擎直接得到 Markdown。
// 模板清单里多加一项「原样输出」(无装饰),它也是本工具此前的默认行为。
const TEMPLATE_PLAIN = { id: 'plain', name: '原样输出', desc: '不加任何标识前缀,原样导出问答内容' };
const TEMPLATES = [
{ id: 'conversation', name: '简洁对话', desc: '简洁的问答格式,用「提问」与「回答」标识每轮对话' },
{ id: 'structured', name: '结构化笔记', desc: '以序号标题概括问题,附完整问题详情与标注前缀的回答' },
{ id: 'knowledgeDoc', name: '知识文档', desc: '将问题作为章节标题,回答作为正文,适合整理成文档' },
{ id: 'rolePlay', name: '角色对话', desc: '用「我说」与「DeepSeek说」标识每轮对话,适合还原真实交流场景' },
];
const QUESTION_TEMPLATES = [
{ id: 'qPlainText', name: '问题清单(纯文本)', desc: '以纯文本序号列出问题,便于复制到聊天或笔记' },
{ id: 'qStructured', name: '问题清单(结构化)', desc: '以序号标题概括提问,附完整问题详情,适合整理问题清单' },
{ id: 'qOutline', name: '问题大纲', desc: '以每个提问作为章节标题,适合梳理需求与待解决问题' },
{ id: 'qCards', name: '问题卡片', desc: '以 Q1/Q2 卡片形式整理提问,适合快速回顾' },
];
const secOf = (wrap, name) => wrap.querySelector('[' + ATTR.SECTION + '="' + name + '"]');
// 全量问题文本:**保留换行**(仅归一 CRLF),换行交给调用方转成 `
`。
// 注意不要在这个函数里压空白 —— 换行要留给调用方转成
,否则多段提问会被挤成一行。
const fullText = (el) => String((el && (el.innerText || el.textContent)) || '').replace(/\r\n?/g, '\n').trim();
// 单行化:连续空白压成一个空格,仅用于「摘要」与「长度判断」。
const oneLine = (s) => String(s || '').replace(/\s+/g, ' ').trim();
const shorten = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n) + '...' : s; };
// 用新节点替换元素内容,但保留其 class / id / 其余属性
function replaceInner(el, nodes) {
const cls = el.className, id = el.id;
const attrs = [];
try { [...el.attributes].forEach(a => { if (a.name !== 'class' && a.name !== 'id') attrs.push([a.name, a.value]); }); } catch (e) { /* 忽略 */ }
el.innerHTML = '';
el.className = cls;
if (id) el.id = id;
attrs.forEach(([k, v]) => { try { el.setAttribute(k, v); } catch (e) { /* 忽略 */ } });
(Array.isArray(nodes) ? nodes : [nodes]).forEach(n => { if (n) el.appendChild(n); });
}
function injectBefore(el, node) {
if (!node) return;
if (el.firstChild) el.insertBefore(node, el.firstChild);
else el.appendChild(node);
}
// 问题正文折叠成单段(`
` 分节),不保留原有块级结构。
function collapseToParagraph(sec) {
const text = fullText(sec);
const p = document.createElement('p');
p.setAttribute('style', 'margin:0');
String(text).split('\n').forEach((line, i) => {
if (i > 0) p.appendChild(document.createElement('br'));
p.appendChild(document.createTextNode(line));
});
replaceInner(sec, p);
}
// 内容标题整体降一级(h1→h2 …),仅当内容里存在 h1 时才降
// 目的:让正文标题层级落在模板插入的 h1 之下
function demoteHeadings(sec) {
let hasH1 = false;
try { hasH1 = !!sec.querySelector('h1'); } catch (e) { hasH1 = false; }
if (!hasH1) return;
let list = [];
try { list = [...sec.querySelectorAll('h1, h2, h3, h4, h5, h6')]; } catch (e) { return; }
for (let i = list.length - 1; i >= 0; i--) {
const h = list[i];
if (!h || !h.parentNode) continue;
const lv = headingLevel(tag(h));
if (!lv || lv >= 6) continue;
const nh = document.createElement('h' + Math.min(lv + 1, 6));
try { [...h.attributes].forEach(a => nh.setAttribute(a.name, a.value)); } catch (e) { /* 忽略 */ }
while (h.firstChild) nh.appendChild(h.firstChild);
try { h.parentNode.replaceChild(nh, h); } catch (e) { /* 忽略 */ }
}
}
const boldLabel = (text) => {
const p = document.createElement('p');
p.setAttribute('style', 'margin:0;margin-bottom:8px');
const s = document.createElement('strong');
s.textContent = text;
p.appendChild(s);
return p;
};
// 结构化笔记 / 问题清单(结构化):序号 h1 + 摘要,超长时补「问题详情」。
// 摘要用**单行化文本**判断长度与截断;「问题详情」用**保留换行**的全文(换行转
)
function structuredHeader(sec, index, hLevel) {
const level = Math.min(Math.max(hLevel || 1, 1), 6);
const full = fullText(sec);
const sum = oneLine(full);
const box = document.createElement('div');
box.setAttribute('style', 'margin-bottom:8px');
const h = document.createElement('h' + level);
h.textContent = index + '、' + shorten(sum, 15);
box.appendChild(h);
if (sum.length > 15) {
const detail = document.createElement('div');
detail.setAttribute('style', 'margin-top:4px;margin-bottom:12px;color:#666');
const p = document.createElement('p');
p.setAttribute('style', 'margin:0');
const s = document.createElement('strong');
s.textContent = '问题详情:';
p.appendChild(s);
p.appendChild(document.createElement('br'));
const lines = full.split('\n');
lines.forEach((line, i) => {
p.appendChild(document.createTextNode(line));
if (i < lines.length - 1) p.appendChild(document.createElement('br'));
});
detail.appendChild(p);
box.appendChild(detail);
}
replaceInner(sec, box);
}
// 知识文档(h1)/ 问题大纲(h2):序号标题,层级由调用方传入。
function outlineHeader(sec, index, hLevel, maxLen) {
const level = Math.min(Math.max(hLevel || 2, 1), 6);
const h = document.createElement('h' + level);
h.textContent = index + '. ' + shorten(oneLine(fullText(sec)), maxLen);
replaceInner(sec, h);
}
const TEMPLATE_DECORATORS = {
conversation: {
onUser: (wrap) => { const s = secOf(wrap, 'question'); if (s) { collapseToParagraph(s); injectBefore(s, boldLabel('提问:')); } },
onAi: (wrap) => { const s = secOf(wrap, 'answer'); if (s) injectBefore(s, boldLabel('回答:')); },
},
structured: {
onUser: (wrap, n) => { const s = secOf(wrap, 'question'); if (s) structuredHeader(s, n, 1); },
onAi: (wrap) => { const s = secOf(wrap, 'answer'); if (!s) return; injectBefore(s, boldLabel('回答(Answer):')); demoteHeadings(s); },
},
knowledgeDoc: {
// 序号标题用 h1(章节标题与 structured 同级)
onUser: (wrap, n) => { const s = secOf(wrap, 'question'); if (s) outlineHeader(s, n, 1, 60); },
onAi: (wrap) => { const s = secOf(wrap, 'answer'); if (s) demoteHeadings(s); },
},
rolePlay: {
// 角色对话 onUser:折叠成单段 + 「我说:」。折叠不可省,否则多段提问会破坏格式
onUser: (wrap) => { const s = secOf(wrap, 'question'); if (s) { collapseToParagraph(s); injectBefore(s, boldLabel('我说:')); } },
onAi: (wrap) => { const s = secOf(wrap, 'answer'); if (s) injectBefore(s, boldLabel('DeepSeek说:')); },
},
qStructured: {
onUser: (wrap, n) => { const s = secOf(wrap, 'question'); if (s) structuredHeader(s, n, 1); },
onAi: () => { },
},
qOutline: {
// 序号标题默认 h2(n=2)
onUser: (wrap, n) => { const s = secOf(wrap, 'question'); if (s) outlineHeader(s, n, 2, 60); },
onAi: () => { },
},
qCards: {
onUser: (wrap, n) => {
const s = secOf(wrap, 'question');
if (!s) return;
const box = document.createElement('div');
box.setAttribute('style', 'margin-bottom:8px');
const h = document.createElement('h1');
h.setAttribute('style', 'margin:0;margin-bottom:4px;color:#4f46e5');
h.textContent = 'Q' + n + ':';
box.appendChild(h);
const bq = document.createElement('blockquote');
bq.setAttribute('style', 'margin:0 0 8px 0;padding:8px 16px;border-left:3px solid #4f46e5;color:#555;background:rgba(79,70,229,0.04);border-radius:0 6px 6px 0');
const p = document.createElement('p');
p.setAttribute('style', 'margin:0');
fullText(s).split('\n').forEach((line, i, arr) => {
p.appendChild(document.createTextNode(line));
if (i < arr.length - 1) p.appendChild(document.createElement('br'));
});
bq.appendChild(p);
box.appendChild(bq);
replaceInner(s, box);
},
onAi: () => { },
},
qPlainText: {
onUser: (wrap, n) => {
const s = secOf(wrap, 'question');
if (!s) return;
const p = document.createElement('p');
p.setAttribute('style', 'margin:0');
p.appendChild(document.createTextNode(n + '. '));
fullText(s).split('\n').forEach((line, i) => {
if (i > 0) p.appendChild(document.createElement('br'));
p.appendChild(document.createTextNode(line));
});
replaceInner(s, p);
},
onAi: () => { },
},
};
// ==================== 模板预览 ====================
// 每张模板卡上方都有一段 132px 的「缩微排版预览」,用真实选中内容渲染,
// 让用户在点之前就看懂模板长什么样。这里按同样思路实现,但复用自己的标准化 DOM。
// 采集预览素材:既有问答时最多 2 组;只选提问时最多 8 条
// 入参是记录数组 [{role, src}],因此**已滚出视野的消息也能参与预览**。
function extractPreview(recs) {
const questions = [], answers = [];
const hasAi = recs.some(r => r.role === 'assistant');
for (const rec of recs) {
let wrap = null;
try { wrap = wrapFromSource(rec.role, rec.src, { includeReasoning: false }); } catch (e) { wrap = null; }
if (!wrap) continue;
// 剥掉自有属性与 style/class/id,只留语义标签(strong/code/table…),交给预览 CSS 接管
try {
[...wrap.querySelectorAll('*')].forEach(n => {
[...n.attributes].forEach(a => {
if (a.name.startsWith(OWN_ATTR_PREFIX) || a.name === 'style' || a.name === 'class' || a.name === 'id') {
n.removeAttribute(a.name);
}
});
});
} catch (e) { /* 忽略 */ }
const html = String(wrap.innerHTML || '').trim();
if (!html) continue;
if (rec.role === 'assistant') answers.push(html); else questions.push(html);
if (hasAi) {
if (questions.length >= 2 && answers.length >= 2) break;
} else if (questions.length >= 8) break;
}
return { questions, answers };
}
// 按纯文本长度截断但**保留 DOM 结构**
function clipHtml(html, limit) {
if (!html) return '';
const holder = document.createElement('div');
holder.innerHTML = html;
if (String(holder.textContent || '').length <= limit) return html;
let used = 0;
const out = document.createElement('div');
const walk = (node, parent) => {
if (used >= limit) return false;
if (node.nodeType === Node.TEXT_NODE) {
const room = limit - used;
const text = node.textContent || '';
if (text.length > room) {
parent.appendChild(document.createTextNode(text.slice(0, room) + '...'));
used = limit;
return false;
}
parent.appendChild(node.cloneNode(true));
used += text.length;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node.cloneNode(false);
parent.appendChild(el);
for (const child of node.childNodes) if (!walk(child, el)) return false;
}
return true;
};
for (const child of holder.childNodes) if (!walk(child, out)) break;
return out.innerHTML;
}
// 取纯文本摘要并转义
function plainHtml(html, limit) {
if (!html) return '';
const holder = document.createElement('div');
holder.innerHTML = html;
const text = String(holder.textContent || '').replace(/\s+/g, ' ').trim();
if (!text) return '';
const cut = text.length > limit ? text.slice(0, limit) + '...' : text;
const esc = document.createElement('div');
esc.textContent = cut;
return esc.innerHTML;
}
const pvC = (inner, mod) => (inner ? `
${inner}` : '');
// 每套模板的缩微预览骨架(自有 ds-md-pv-* 前缀,避免与页面样式冲突)
function previewHtml(id, texts) {
const q = (texts && texts.questions) || [];
const a = (texts && texts.answers) || [];
const n = q[0] || '', o = q[1] || '', r = q[2] || '', i = q[3] || '', s4 = q[4] || '', s5 = q[5] || '';
const l = a[0] || '', d = a[1] || '';
const line = (cls, html) => `${html}`;
const H1 = 'ds-md-pv-line--h1', BOLD = 'ds-md-pv-line--bold', ACC = 'ds-md-pv-line--accent';
const MUTED = 'ds-md-pv-c--muted';
const div = '';
const gap = '';
const sec = (inner) => `${inner}`;
const maps = {
plain: () => sec(pvC(clipHtml(n, 200))) +
(l ? gap + sec(pvC(clipHtml(l, 200))) : ''),
structured: () => sec(
line(H1, '1、' + plainHtml(n, 15)) +
line(BOLD, '问题详情:') +
pvC(clipHtml(n, 100), MUTED) + gap +
line(BOLD, '回答(Answer):') + pvC(clipHtml(l, 150))
) + (o ? div + sec(
line(H1, '2、' + plainHtml(o, 15)) +
line(BOLD, '问题详情:') +
pvC(clipHtml(o, 100), MUTED) + gap +
line(BOLD, '回答(Answer):') + pvC(clipHtml(d, 150))
) : ''),
conversation: () => sec(
line(BOLD, '提问:') + pvC(clipHtml(n, 100), MUTED) + gap +
line(BOLD, '回答:') + pvC(clipHtml(l, 200))
) + (o ? div + sec(
line(BOLD, '提问:') + pvC(clipHtml(o, 100), MUTED) + gap +
line(BOLD, '回答:') + pvC(clipHtml(d, 200))
) : ''),
knowledgeDoc: () => sec(
line(H1, '1. ' + plainHtml(n, 40)) + pvC(clipHtml(l, 250))
) + (o ? gap + sec(
line(H1, '2. ' + plainHtml(o, 40)) + pvC(clipHtml(d, 250))
) : ''),
rolePlay: () => sec(
line(BOLD, '我说:') + pvC(clipHtml(n, 100), MUTED) + gap +
line(BOLD, 'DeepSeek说:') + pvC(clipHtml(l, 200))
) + (o ? div + sec(
line(BOLD, '我说:') + pvC(clipHtml(o, 100), MUTED) + gap +
line(BOLD, 'DeepSeek说:') + pvC(clipHtml(d, 200))
) : ''),
qStructured: () => sec(
line(H1, '1、' + plainHtml(n, 15)) +
line(BOLD, '问题详情:') + pvC(clipHtml(n, 160), MUTED)
) + (o ? div + sec(
line(H1, '2、' + plainHtml(o, 15)) +
line(BOLD, '问题详情:') + pvC(clipHtml(o, 160), MUTED)
) : ''),
qOutline: () => sec(line(H1, '1. ' + plainHtml(n, 60))) +
(o ? sec(line(H1, '2. ' + plainHtml(o, 60))) : '') +
(r ? sec(line(H1, '3. ' + plainHtml(r, 60))) : '') +
(i ? sec(line(H1, '4. ' + plainHtml(i, 60))) : '') +
(s4 ? sec(line(H1, '5. ' + plainHtml(s4, 60))) : ''),
qCards: () => `` +
line(ACC + ' ' + BOLD, 'Q1:') +
`${pvC(clipHtml(n, 140))}` +
(o ? `` +
line(ACC + ' ' + BOLD, 'Q2:') +
`${pvC(clipHtml(o, 140))}` : ''),
qPlainText: () => sec(
line('', '1. ' + plainHtml(n, 92)) +
(o ? line('', '2. ' + plainHtml(o, 92)) : '') +
(r ? line('', '3. ' + plainHtml(r, 92)) : '') +
(i ? line('', '4. ' + plainHtml(i, 92)) : '') +
(s4 ? line('', '5. ' + plainHtml(s4, 92)) : '') +
(s5 ? line('', '6. ' + plainHtml(s5, 92)) : '')
),
};
const fn = maps[id];
return fn ? fn() : '';
}
// 按选中内容返回可选模板清单:
// 只有提问(无回答)→ 问题清单类;否则 → 问答类
function templatesFor(hasUser, hasAi) {
const base = (hasUser && !hasAi) ? QUESTION_TEMPLATES : TEMPLATES;
return [TEMPLATE_PLAIN].concat(base);
}
// 采集当前对话的记录(键驱动)。虚拟列表下会先刷新可见消息的来源快照,
// 已滚过的消息保留早先的快照 —— 两者合起来才是「整段对话」。
function collectRecords() {
rememberMessages();
// pending:已虚拟化但尚未扫描时,窗口外还有消息(扫描后即为 0)
let pending = 0;
try {
if (isVirtualized() && !scanDone) {
const sc = findScrollContainer();
if (sc && maxScrollTop(sc) > 40) pending = -1; // -1 = 数量未知
}
} catch (e) { pending = 0; }
return { keys: orderedKeys(), pending };
}
// ---------- 文件名生成 ----------
const FALLBACK_TITLE = 'DeepSeek对话';
function resolveTitle() {
let t = String(document.title || '').trim();
const SUFFIX = ' - DeepSeek';
if (t.endsWith(SUFFIX)) t = t.slice(0, -SUFFIX.length).trim();
return t || FALLBACK_TITLE;
}
const pad2 = (n) => String(n).padStart(2, '0');
// 文件名时间戳:YYYY-MM-DD-HH-mm(带时分,避免同日多次导出互相覆盖)
function formatExportDate(d) {
const t = (d instanceof Date && !Number.isNaN(d.getTime())) ? d : new Date();
return `${t.getFullYear()}-${pad2(t.getMonth() + 1)}-${pad2(t.getDate())}-${pad2(t.getHours())}-${pad2(t.getMinutes())}`;
}
// 空格→下划线、Windows 非法字符→下划线、截断到 limit 字(默认 50)
const FILENAME_MAX = 50;
function sanitizeFilename(name, limit) {
return (String(name == null ? '' : name).trim() || FALLBACK_TITLE)
.replace(/\s+/g, '_')
.replace(/[<>:"/\\|?*]/g, '_')
.substring(0, typeof limit === 'number' && limit > 0 ? limit : FILENAME_MAX);
}
function buildFilename(ext) {
const base = resolveTitle();
const stamp = mdExportAppendDate ? formatExportDate() : '';
// 时间戳必须原样保留:先按「总长 - 时间戳 - 连接符」截标题,再拼接。
// 若先拼后截(旧实现),长标题会把时间戳整段截掉,「避免重名覆盖」的承诺即失效。
if (!stamp) return sanitizeFilename(base) + '.' + ext;
const room = FILENAME_MAX - stamp.length - 1;
return sanitizeFilename(base, room > 0 ? room : FILENAME_MAX) + '-' + stamp + '.' + ext;
}
function download(text, filename, mime) {
const blob = new Blob([text], { type: mime || 'text/markdown;charset=utf-8' }); // 不写 BOM:Markdown / JSON 都按 UTF-8 原文
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
}
// opts: { includeReasoning, templateId, selectedKeys(Set|null) }
// 注意:**没有**「仅导出 AI 回答」这类选项 —— 导出哪些消息由勾选态决定
//(勾选态已提供「全选提问 / 全选AI回答」)。在弹窗里再放一个同类开关会与之打架。
function buildMarkdown(opts) {
const o = opts && typeof opts === 'object' ? opts : {};
const includeReasoning = o.includeReasoning === true;
const templateId = o.templateId || null;
const sel = o.selectedKeys instanceof Set ? o.selectedKeys : null;
const { keys, pending } = collectRecords();
if (!keys.length) return { ok: false, error: '未找到对话消息,请确认已打开一个对话。' };
const list = sel ? keys.filter(k => sel.has(k)) : keys;
if (!list.length) return { ok: false, error: '没有选中的消息。' };
const recs = list.map(k => sourceCache.get(k)).filter(Boolean);
if (!recs.length) return { ok: false, error: '没有可导出的内容。' };
const hasUser = recs.some(r => r.role === 'user');
const hasAi = recs.some(r => r.role === 'assistant');
const dec = templateId && TEMPLATE_DECORATORS[templateId] ? TEMPLATE_DECORATORS[templateId] : null;
const parts = [];
let aiCount = 0, userCount = 0, reasoningFound = 0;
recs.forEach(rec => {
const wrap = wrapFromSource(rec.role, rec.src, { includeReasoning });
if (!wrap) return;
// 模板装饰:用户消息序号自增(供模板编号使用)
if (rec.role === 'user') userCount++;
if (dec) {
const fn = rec.role === 'user' ? dec.onUser : dec.onAi;
try { if (typeof fn === 'function') fn(wrap, userCount); } catch (e) { console.warn('[deepseektool] 模板装饰失败:', e); }
}
const md = toMarkdown(wrap);
if (!md) return;
if (rec.role === 'assistant') {
aiCount++;
// 「勾了导出思考过程却什么都没多」是高频困惑点 —— 统计实际命中数,交给提示层回显
if (includeReasoning && rec.src && rec.src.reasoningHTML) reasoningFound++;
}
parts.push(md);
});
if (!parts.length) return { ok: false, error: '对话内容为空。' };
const head = '# ' + resolveTitle() + '\n\n';
const body = parts.join('\n\n---\n\n');
return { ok: true, md: head + body + '\n', count: parts.length, aiCount, pending, hasUser, hasAi, reasoningFound };
}
// ---------- JSON 导出 ----------
// 每条消息一条记录,content 为 Markdown 文本(保留代码块 / 表格 / 公式),
// reasoning 单独成字段 —— 消费方不必再靠分隔符或前缀去拆内容。
const JSON_SCHEMA = 'deepseektool.export.v1';
function reasoningMarkdown(src) {
if (!src || !src.reasoningHTML) return '';
const box = document.createElement('div');
box.innerHTML = src.reasoningHTML;
normalizeContent(document, box, {});
return toMarkdown(box) || '';
}
function buildJson(opts) {
const o = opts && typeof opts === 'object' ? opts : {};
const includeReasoning = o.includeReasoning === true;
const sel = o.selectedKeys instanceof Set ? o.selectedKeys : null;
const { keys, pending } = collectRecords();
if (!keys.length) return { ok: false, error: '未找到对话消息,请确认已打开一个对话。' };
const list = sel ? keys.filter(k => sel.has(k)) : keys;
if (!list.length) return { ok: false, error: '没有选中的消息。' };
const messages = [];
let reasoningFound = 0;
list.forEach(k => {
const rec = sourceCache.get(k);
if (!rec) return;
const wrap = wrapFromSource(rec.role, rec.src, { includeReasoning: false });
if (!wrap) return;
const content = toMarkdown(wrap);
if (!content) return;
const item = { index: messages.length, role: rec.role, content };
if (rec.role === 'assistant' && includeReasoning) {
const reasoning = reasoningMarkdown(rec.src);
if (reasoning) { item.reasoning = reasoning; reasoningFound++; }
}
messages.push(item);
});
if (!messages.length) return { ok: false, error: '对话内容为空。' };
const payload = {
schema: JSON_SCHEMA,
contentFormat: 'markdown', // content / reasoning 都是 Markdown 文本,消费方按此解析
title: resolveTitle(),
url: String(location.href || ''),
exportedAt: new Date().toISOString(),
messageCount: messages.length,
messages,
};
return {
ok: true,
text: JSON.stringify(payload, null, 2) + '\n',
count: messages.length,
pending,
reasoningFound,
};
}
// ---------- 稳定键 / 来源快照 / 全对话扫描 ----------
// 三者配合才能把长对话导全:
//
// 为什么必须这样做:DeepSeek 用虚拟列表渲染,**只渲染可视窗口内的消息**;
// 一旦滚过去,React 会卸载这些节点。所以
// ① 选择状态必须按「稳定键」而非元素引用保存(否则滚出视野就丢选中);
// ② 必须在消息可见的当下就**快照**其原始素材,导出时才有东西可用。
// 稳定键:优先虚拟列表项 key(跨滚动不变),回退 DOM 序号
function stableKeyOf(msgEl, index) {
try {
const item = msgEl && msgEl.closest ? msgEl.closest(SEL.VIRTUAL_ITEM) : null;
const k = item && item.getAttribute('data-virtual-list-item-key');
if (k && String(k).trim()) return 'vk:' + String(k).trim();
} catch (e) { /* 忽略 */ }
const i = typeof index === 'number' ? index : msgsInDom().indexOf(msgEl);
return 'ix:' + (i >= 0 ? i : 0);
}
// 稳定键排序(同前缀按数字,否则按中文序)
function compareStableKeys(a, b) {
const parse = (s) => {
const m = /^([a-z]+):(-?\d+)$/i.exec(String(s == null ? '' : s).trim());
return m ? { p: m[1].toLowerCase(), n: Number(m[2]) } : null;
};
const x = parse(a), y = parse(b);
if (x && y && x.p === y.p) return x.n - y.n;
return String(a).localeCompare(String(b), 'zh-Hans-CN');
}
// 来源快照:只存**原始素材**(问题体 / 思考体 / 答案体的 HTML),与导出选项解耦,
// 这样「包含思考过程」等选项在导出时才生效,不污染快照。
function snapshotSource(msgEl, role) {
if (!msgEl) return null;
if (role === 'assistant') {
const ai = resolveAiContent(msgEl);
if (!ai) return null;
let reasoningHTML = '';
try {
const nodes = collectReasoningNodes(msgEl);
if (nodes.length) reasoningHTML = nodes.map(n => n.outerHTML).join('');
} catch (e) { /* 忽略 */ }
return { role, reasoningHTML, answerHTML: ai.outerHTML };
}
const root = resolveUserRoot(msgEl);
if (!root) return null;
return { role, questionHTML: root.outerHTML };
}
const sourceCache = new Map(); // key -> { key, role, src }
let orderList = []; // 已发现的键,按对话顺序
let scanDone = false;
const noteOrder = (keys) => { keys.forEach(k => { if (k && !orderList.includes(k)) orderList.push(k); }); };
const orderedKeys = () => orderList.filter(k => sourceCache.has(k));
// 把当前 DOM 里的可见消息写入缓存(可见的重取,保证流式内容最新;已滚过的保留旧快照)
function rememberMessages() {
const list = msgsInDom();
const keys = [];
list.forEach((el, i) => {
const key = stableKeyOf(el, i);
const role = resolveAiContent(el) ? 'assistant' : 'user';
let src = null;
try { src = snapshotSource(el, role); } catch (e) { src = null; }
if (!src) return;
sourceCache.set(key, { key, role, src });
keys.push(key);
});
noteOrder(keys);
return keys;
}
// 找出承载消息列表的滚动容器
// 滚动容器定位:从虚拟列表容器出发,沿祖先链向上逐级检查。
// 找第一个 overflowY ∈ {auto,scroll} 且确实可滚动的元素。
// ⚠ 绝不能全页扫 .ds-scroll-area —— 实测页面上有 30+ 个(代码块横向滚动区、侧栏、以及
// 装饰性的 .ds-scroll-area__gutters),"第一个可滚动的"往往**根本不含消息**;
// 一旦滚错容器,扫描会静默地一条也采集不到。
function findScrollContainer() {
let list = null;
try { list = document.querySelector(SEL.VIRTUAL_LIST); } catch (e) { list = null; }
if (list) {
let node = list.parentElement;
while (node && node !== document.body) {
let oy = '';
try { oy = String(getComputedStyle(node).overflowY || ''); } catch (e) { oy = ''; }
if ((oy === 'auto' || oy === 'scroll') && node.scrollHeight > node.clientHeight + 4) return node;
node = node.parentElement;
}
}
// 回退:整页滚动(非虚拟化或列表未用内层滚动时)
try {
const se = document.scrollingElement;
if (se && se.scrollHeight > se.clientHeight) return se;
} catch (e) { /* 忽略 */ }
return null;
}
const isVirtualized = () => !!document.querySelector(SEL.VIRTUAL_LIST);
const maxScrollTop = (sc) => (sc ? Math.max(0, sc.scrollHeight - sc.clientHeight) : 0);
const nextPaint = (n) => new Promise(res => {
const tick = (k) => (k <= 0 ? res() : requestAnimationFrame(() => tick(k - 1)));
tick(Math.max(1, n || 1));
});
// 窗口签名:当前渲染出来的消息键序列(不变即视为稳定)
const windowSignature = () => msgsInDom().map((el, i) => stableKeyOf(el, i)).join(',');
// 等待窗口稳定:连续两次签名一致即返回(上限 24 次)
async function waitWindowStable(sc, maxTries) {
const tries = maxTries || 24;
let prev = '';
for (let i = 0; i < tries; i++) {
await nextPaint(1);
const sig = windowSignature();
if (sig && sig === prev) return true;
prev = sig;
}
return !!prev;
}
// 单步采集:滚到指定位置 → 等稳定 → 记录窗口内消息
async function captureAt(sc, top) {
sc.scrollTop = Math.max(0, Math.min(maxScrollTop(sc), Math.round(top)));
await waitWindowStable(sc);
// 图表态代码块先切回代码视图,保证快照里 pre 有内容
try { if (needsCodeViewSwitch(document)) await ensureCodeView(document); } catch (e) { /* 忽略 */ }
// 内容还没渲染出来时,多给几次机会
let keys = rememberMessages();
for (let i = 0; i < 3 && !keys.length; i++) {
await new Promise(r => setTimeout(r, 40));
await waitWindowStable(sc);
keys = rememberMessages();
}
return keys;
}
// 全对话扫描:逐步滚过整段对话,边滚边快照(滚到位置 → 等窗口稳定 → 采集)
async function scanAll() {
if (scanDone) return true;
const sc = findScrollContainer();
if (!sc || maxScrollTop(sc) < 40) { scanDone = true; rememberMessages(); return true; }
const original = sc.scrollTop;
const step = Math.max(240, Math.floor(0.45 * sc.clientHeight));
orderList = []; // 按滚动顺序重建对话顺序
showNotice('正在扫描整段对话,请稍候…');
if (controlsEl) controlsEl.classList.add(CLS.SCANNING);
document.body.classList.add(CLS.SCANNING);
try {
let pos = 0, guard = 0;
while (guard < 400) {
guard++;
const keys = await captureAt(sc, pos);
if (!keys.length && pos >= maxScrollTop(sc)) break;
if (pos >= maxScrollTop(sc)) break;
const next = Math.min(maxScrollTop(sc), pos + step);
if (next <= pos + 1) break;
pos = next;
}
// 再冲几次底部,确保末尾消息被渲染
for (let i = 0; i < 3; i++) await captureAt(sc, maxScrollTop(sc));
// 兜底:扫描期间始终没进过窗口的键,按稳定键排序后补到末尾。
// 注意不能就地比较「当前下标」——那等于不排序;必须交给 compareStableKeys。
const noted = new Set(orderList);
const rest = [...sourceCache.keys()].filter(k => !noted.has(k)).sort(compareStableKeys);
orderList.push(...rest);
scanDone = true;
return true;
} finally {
sc.scrollTop = original;
await waitWindowStable(sc);
rememberMessages(); // 恢复视野后刷新一次可见消息
hideNotice();
if (controlsEl) controlsEl.classList.remove(CLS.SCANNING);
document.body.classList.remove(CLS.SCANNING);
}
}
// ---------- 图表态代码块切回「代码」视图 ----------
// 图表态代码块:双 Tab(代码/图表)且当前不在代码视图 → 需要切换。
// **不需要主世界注入**:DOM 在两个 world 间共享,React 的委托监听挂在 document 上,
// 沙箱内 click() 派发的真实事件它能收到 —— 顺带绕开页面 CSP 拦截内联脚本的风险。
const TAB_SEL = 'div[role="tab"], .ds-segmented-button';
function getSwitchTabs(block) {
let tabs = [];
try { tabs = [...block.querySelectorAll(TAB_SEL)]; } catch (e) { return { hasCode: false, hasDiagram: false, codeSelected: false, codeTab: null }; }
let hasCode = false, hasDiagram = false, codeSelected = false, codeTab = null;
tabs.forEach(t => {
const text = String(t.textContent || '').trim();
if (!text) return;
const low = text.toLowerCase();
const sel = t.getAttribute('aria-selected') === 'true' ||
(t.classList && t.classList.contains('ds-segmented-button--selected'));
if (text.includes('代码') || low.includes('code')) { hasCode = true; codeTab = t; if (sel) codeSelected = true; }
if (text.includes('图表') || low.includes('chart') || low.includes('diagram')) hasDiagram = true;
});
return { hasCode, hasDiagram, codeSelected, codeTab };
}
function needsCodeViewSwitch(scope) {
let blocks = [];
try { blocks = [...scope.querySelectorAll(SEL.CODE_BLOCK)]; } catch (e) { return false; }
for (const b of blocks) {
const t = getSwitchTabs(b);
if (!t.hasCode || !t.hasDiagram) continue;
if (!t.codeSelected) return true;
let hasPre = false;
try { hasPre = !!(b.querySelector('pre code') || b.querySelector('pre')); } catch (e) { hasPre = false; }
if (!hasPre) return true;
}
return false;
}
// 切回代码视图并轮询等待(6 × 30ms)
async function ensureCodeView(scope) {
if (!scope) return false;
let blocks = [];
try { blocks = [...scope.querySelectorAll(SEL.CODE_BLOCK)]; } catch (e) { return false; }
let clicked = false;
blocks.forEach(b => {
const t = getSwitchTabs(b);
if (!t.hasCode || !t.hasDiagram || t.codeSelected) return;
try { if (t.codeTab) { t.codeTab.click(); clicked = true; } } catch (e) { /* 忽略 */ }
});
if (!clicked) return false;
for (let i = 0; i < 6; i++) {
await new Promise(r => setTimeout(r, 30));
if (!needsCodeViewSwitch(scope)) return true;
}
return false;
}
// ---------- 右上角提示条(毛玻璃底 + 旋转环) ----------
let noticeEl = null;
function showNotice(text) {
hideNotice();
noticeEl = document.createElement('div');
noticeEl.className = 'ds-md-notice';
noticeEl.innerHTML = '' +
'' +
'';
noticeEl.querySelector('.ds-md-notice-message').textContent = text;
document.body.appendChild(noticeEl);
}
function hideNotice() {
if (!noticeEl) return;
const el = noticeEl;
noticeEl = null;
try {
el.classList.add('closing');
setTimeout(() => { try { el.remove(); } catch (e) { /* 忽略 */ } }, 200);
} catch (e) { try { el.remove(); } catch (e2) { /* 忽略 */ } }
}
// ---------- 选择模式 ----------
// 勾选态:复选框注入 + 控制条 + 选中高亮。
const CLS = {
ACTIVE: 'ds-md-selection-active',
SCANNING: 'ds-md-selection-scanning',
CB_WRAP: 'ds-md-cb-wrap',
CB_USER: 'ds-md-cb-user',
CB_AI: 'ds-md-cb-ai',
CB: 'ds-md-checkbox',
SELECTED: 'ds-md-msg-selected',
SEL_USER: 'ds-md-msg-user',
SEL_AI: 'ds-md-msg-ai',
CONTROLS: 'ds-md-controls',
SELECT_ALL: 'ds-md-select-all',
SELECT_USER: 'ds-md-select-user',
SELECT_AI: 'ds-md-select-ai',
EXPORT_SEL: 'ds-md-export-selected',
CANCEL: 'ds-md-cancel-sel',
HINT: 'ds-md-sel-hint',
MODAL: 'ds-md-modal',
DIALOG: 'ds-md-dialog',
STEP_CONTENT: 'ds-md-step-content',
STEP_TEMPLATE: 'ds-md-step-template',
FMT_BTN: 'ds-md-fmt-btn',
TPL_CARD: 'ds-md-template-card',
REMEMBER: 'ds-md-remember',
BACK: 'ds-md-back',
ACTIONS: 'ds-md-dlg-actions',
CANCEL_DLG: 'ds-md-dlg-cancel',
TITLE: 'ds-md-dlg-title',
SUB: 'ds-md-dlg-sub',
};
const selectedKeys = new Set(); // 选中的**稳定键**(不是元素引用)
let selectionActive = false;
let selObserver = null;
let controlsEl = null;
let syncTimer = null;
let dialogEl = null;
let dialogCleanup = null;
// ---------- 会话守卫 ----------
// SPA 切换对话时,消息 DOM 会被整段替换,但本单元的 sourceCache / orderList / selectedKeys
// 仍是上一段对话的键。若不失效,导出会把两段对话拼在一起(稳定键还可能撞号)。
// 语义对齐:页面变化 → 清空缓存与选中 → 退出勾选态,并明确告知用户。
const sessionIdOf = () => {
const m = String(location.pathname || '').match(/\/s\/([^/]+)/);
return m ? m[1] : '';
};
let selectionSid = '';
function resetSelectionCache() {
selectedKeys.clear();
sourceCache.clear();
orderList = [];
scanDone = false;
}
function guardSessionChange() {
if (!selectionActive) return false;
const sid = sessionIdOf();
if (sid === selectionSid) return false;
selectionSid = sid;
resetSelectionCache();
stopSelection();
showToast('检测到对话切换,已退出导出选择');
return true;
}
const msgsInDom = () => [...document.querySelectorAll(SEL.MESSAGE)];
function attachCheckbox(msgEl, isUser, key) {
if (!msgEl) return;
const k = key || stableKeyOf(msgEl);
let wrap = null, cb = null;
try { wrap = msgEl.querySelector('.' + CLS.CB_WRAP); } catch (e) { wrap = null; }
if (wrap) {
cb = wrap.querySelector('.' + CLS.CB);
wrap.classList.toggle(CLS.CB_USER, isUser);
wrap.classList.toggle(CLS.CB_AI, !isUser);
} else {
wrap = document.createElement('div');
wrap.className = CLS.CB_WRAP + ' ' + (isUser ? CLS.CB_USER : CLS.CB_AI);
cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = CLS.CB;
cb.setAttribute('aria-label', '选择此条消息');
cb.addEventListener('change', (e) => {
e.stopPropagation();
// 每次现算键:React 会重建节点,闭包里捕获的旧节点可能已脱离文档
const live = stableKeyOf(msgEl);
if (e.target.checked) selectedKeys.add(live); else selectedKeys.delete(live);
applyHighlight(msgEl);
updateControls();
});
wrap.appendChild(cb);
// 挂载前确保定位上下文(否则 absolute 复选框会跑到页面左上角)
if (getComputedStyle(msgEl).position === 'static') msgEl.style.position = 'relative';
msgEl.appendChild(wrap);
}
if (cb) { cb.dataset.mdKey = k; cb.checked = selectedKeys.has(k); }
}
function syncCheckboxes() {
if (!selectionActive) return; // 已退出勾选态(含会话守卫触发)时不得再注入复选框
if (guardSessionChange()) return; // 切换对话后不再往新会话里画上一轮的勾选
rememberMessages(); // 先刷新可见消息的来源快照(键驱动的前提)
const list = msgsInDom();
list.forEach((el, i) => {
const key = stableKeyOf(el, i);
attachCheckbox(el, !resolveAiContent(el), key);
});
list.forEach(applyHighlight);
}
function removeCheckboxes() {
document.querySelectorAll('.' + CLS.CB_WRAP).forEach(el => {
try {
const msg = el.closest ? el.closest(SEL.MESSAGE) : null;
if (msg) msg.style.position = '';
} catch (e) { /* 忽略 */ }
el.remove();
});
document.querySelectorAll('.' + CLS.SELECTED).forEach(el => {
el.classList.remove(CLS.SELECTED, CLS.SEL_USER, CLS.SEL_AI);
el.style.position = '';
});
}
function highlightTarget(msgEl, isUser) {
if (!msgEl || !isUser) return msgEl;
try {
const r = resolveUserRoot(msgEl);
if (r && r !== msgEl) return r;
} catch (e) { /* 忽略 */ }
return msgEl;
}
function clearHighlight(msgEl, keep) {
if (!msgEl || !msgEl.querySelectorAll) return;
const clear = (n) => {
if (!n || n === keep) return;
try { n.classList.remove(CLS.SELECTED, CLS.SEL_USER, CLS.SEL_AI); } catch (e) { /* 忽略 */ }
};
clear(msgEl);
try { msgEl.querySelectorAll('.' + CLS.SELECTED).forEach(clear); } catch (e) { /* 忽略 */ }
}
function applyHighlight(msgEl) {
if (!msgEl) return;
const isUser = !resolveAiContent(msgEl);
const on = selectedKeys.has(stableKeyOf(msgEl));
try {
const cb = msgEl.querySelector('.' + CLS.CB);
if (cb) cb.checked = on;
} catch (e) { /* 忽略 */ }
const t = highlightTarget(msgEl, isUser);
clearHighlight(msgEl, t);
if (!t) return;
t.classList.toggle(CLS.SELECTED, on);
t.classList.remove(CLS.SEL_USER, CLS.SEL_AI);
if (on) t.classList.add(isUser ? CLS.SEL_USER : CLS.SEL_AI);
}
function createControls() {
const box = document.createElement('div');
box.className = CLS.CONTROLS;
const mk = (cls, text, onClick) => {
const b = document.createElement('button');
b.type = 'button';
b.className = cls;
b.textContent = text;
b.addEventListener('click', e => { e.stopPropagation(); onClick(); });
return b;
};
const all = mk(CLS.SELECT_ALL, '全选', () => { void selectAll(); });
const user = mk(CLS.SELECT_USER, '全选提问', () => { void selectByRole('user'); });
const ai = mk(CLS.SELECT_AI, '全选AI回答', () => { void selectByRole('assistant'); });
const exp = mk(CLS.EXPORT_SEL, '导出选中 (0)', () => { void exportSelected(); });
const cancel = mk(CLS.CANCEL, '取消', () => stopSelection());
const hint = document.createElement('div');
hint.className = CLS.HINT;
hint.textContent = '可手动勾选想要导出的内容';
const tail = document.createElement('div');
tail.className = 'ds-md-cancel-wrap';
tail.appendChild(cancel);
tail.appendChild(hint);
box.appendChild(all);
box.appendChild(user);
box.appendChild(ai);
box.appendChild(exp);
box.appendChild(tail);
return box;
}
let busyCount = 0; // 进行中的扫描次数(>0 时禁用控制条按钮)
function updateControls() {
if (!controlsEl) return;
const total = orderedKeys().length;
const n = selectedKeys.size;
const q = (s) => controlsEl.querySelector('.' + s);
const expBtn = q(CLS.EXPORT_SEL);
const allBtn = q(CLS.SELECT_ALL);
const userBtn = q(CLS.SELECT_USER);
const aiBtn = q(CLS.SELECT_AI);
const hint = q(CLS.HINT);
const busy = busyCount > 0;
// 文案判据是「页面上有没有可导出的消息」(total),不是「选没选中」(n)。
// 用 n 判会让刚进入勾选态的用户看到红字「当前页无对话内容」——而消息明明就在眼前。
const hasContent = total > 0;
const complete = scanDone || !isVirtualized();
if (hint) {
hint.textContent = hasContent ? '可手动勾选想要导出的内容' : '当前页无对话内容';
hint.classList.toggle('is-ok', hasContent); // 有内容 → 灰字提示;无内容 → 红字警告
}
[allBtn, userBtn, aiBtn].forEach(b => { if (b) b.disabled = busy || !hasContent; });
if (expBtn) {
expBtn.textContent = `导出选中 (${n})`;
expBtn.disabled = busy || n <= 0;
}
// 「取消全选」只在确认已扫描完整时才出现,否则会暗示「已经没有更多消息了」
if (allBtn) allBtn.textContent = (n > 0 && n === total && complete) ? '取消全选' : '全选';
}
// 需要时先扫全对话(虚拟列表下才真正滚动;短对话直接标记完成)
async function ensureScanned() {
if (guardSessionChange()) return false;
if (scanDone) return true;
if (!isVirtualized()) { scanDone = true; rememberMessages(); return true; }
busyCount++;
updateControls();
try {
return await scanAll();
} catch (e) {
console.warn('[deepseektool] 扫描对话失败:', e);
return false;
} finally {
busyCount = Math.max(0, busyCount - 1);
syncCheckboxes();
updateControls();
}
}
async function selectAll() {
await ensureScanned();
const keys = orderedKeys();
const willSelect = selectedKeys.size < keys.length;
selectedKeys.clear();
if (willSelect) keys.forEach(k => selectedKeys.add(k));
syncCheckboxes();
updateControls();
}
async function selectByRole(role) {
await ensureScanned();
selectedKeys.clear();
orderedKeys().forEach(k => {
const rec = sourceCache.get(k);
if (rec && rec.role === role) selectedKeys.add(k);
});
syncCheckboxes();
updateControls();
}
function startObserver() {
stopObserver();
selObserver = new MutationObserver(muts => {
let need = false;
for (const m of muts) {
if (m.type !== 'childList' || !m.addedNodes.length) continue;
for (const node of m.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// 只认「新增了消息或含消息的子树」;复选框自身注入不会命中 → 不自激
if ((node.matches && node.matches(SEL.MESSAGE)) ||
(node.querySelector && node.querySelector(SEL.MESSAGE))) { need = true; break; }
}
if (need) break;
}
if (!need) return;
clearTimeout(syncTimer);
syncTimer = setTimeout(() => { if (selectionActive) { syncCheckboxes(); updateControls(); } }, 150);
});
selObserver.observe(document.body, { childList: true, subtree: true });
}
function stopObserver() {
clearTimeout(syncTimer);
syncTimer = null;
if (selObserver) { try { selObserver.disconnect(); } catch (e) { /* 忽略 */ } selObserver = null; }
}
function startSelection() {
if (selectionActive) return true;
if (!msgsInDom().length) { showToast('当前页无对话内容'); return false; }
selectionActive = true;
// 每次进入都是全新会话:清空上轮的键、顺序与快照(避免跨对话残留)
resetSelectionCache();
selectionSid = sessionIdOf();
busyCount = 0;
document.body.classList.add(CLS.ACTIVE);
syncCheckboxes();
controlsEl = createControls();
document.body.appendChild(controlsEl);
updateControls();
startObserver();
document.addEventListener('keydown', onSelectionKey, true);
return true;
}
function onSelectionKey(e) {
if (!selectionActive) return;
if (e.key !== 'Escape') return;
if (document.querySelector('.' + CLS.MODAL)) return; // 模态框打开时 Esc 归模态框处理
e.stopPropagation();
stopSelection();
}
function stopSelection() {
if (!selectionActive) return;
selectionActive = false;
selectedKeys.clear();
document.body.classList.remove(CLS.ACTIVE, CLS.SCANNING);
document.removeEventListener('keydown', onSelectionKey, true);
stopObserver();
hideNotice();
removeCheckboxes();
if (controlsEl) { try { controlsEl.remove(); } catch (e) { /* 忽略 */ } controlsEl = null; }
}
// ---------- 模态框(两步:导出内容 → 内容模板) ----------
// 两步弹窗。本工具只有 Markdown 一种格式,故步 1 只列格式
// 不作为分叉条件(点格式即进入步 2),并把「记住我的选择」用于**预填**而非跳过模板步。
// 弹窗选项的唯一来源:用户上次勾了「记住我的选择」才生效;否则一律走硬编码默认值。
// 绝不回落读旧设置键 —— 那会让一个已从设置面板移除的开关继续隐形生效。
// 默认「导出思考过程 = 开」。
const DEFAULT_INCLUDE_REASONING = true;
const ALL_TEMPLATE_IDS = new Set(
[TEMPLATE_PLAIN.id].concat(TEMPLATES.map(t => t.id), QUESTION_TEMPLATES.map(t => t.id))
);
function currentPrefs() {
let saved = null;
try { saved = JSON.parse(GM_getValue(STORAGE_MD_EXPORT_PREFS, '') || 'null'); } catch (e) { saved = null; }
// 只有用户真的勾过「记住我的选择」才会有落库值;没有就用默认
const hasSaved = !!saved && typeof saved === 'object';
const s = hasSaved ? saved : {};
return {
includeReasoning: typeof s.includeReasoning === 'boolean' ? s.includeReasoning : DEFAULT_INCLUDE_REASONING,
templateId: ALL_TEMPLATE_IDS.has(s.templateId) ? s.templateId : TEMPLATE_PLAIN.id,
remember: s.remember === true,
};
}
function savePrefs(p) {
try { GM_setValue(STORAGE_MD_EXPORT_PREFS, JSON.stringify(p)); } catch (e) { /* 忽略 */ }
}
// 清除记忆 = 清掉「记住我的选择」+ 一并归零 v1 遗留键(否则老用户会一直被隐形开关影响)
function clearPrefs() {
try { GM_setValue(STORAGE_MD_EXPORT_PREFS, ''); } catch (e) { /* 忽略 */ }
LEGACY_MD_KEYS.forEach(k => { try { GM_setValue(k, false); } catch (e) { /* 忽略 */ } });
}
const templateNameOf = (id) => {
if (!id) return '';
if (id === TEMPLATE_PLAIN.id) return TEMPLATE_PLAIN.name;
const hit = TEMPLATES.concat(QUESTION_TEMPLATES).find(t => t.id === id);
return hit ? hit.name : '';
};
function openDialog() {
// 从缓存取记录(可能包含已滚出视野的消息)—— 这正是长对话能导全的原因
const recs = orderedKeys().filter(k => selectedKeys.has(k)).map(k => sourceCache.get(k)).filter(Boolean);
if (!recs.length) { showToast('请先选择要导出的消息'); return; }
closeDialog();
const hasUser = recs.some(r => r.role === 'user');
const hasAi = recs.some(r => r.role === 'assistant');
const nUser = recs.filter(r => r.role === 'user').length;
const nAi = recs.length - nUser;
const prefs = currentPrefs();
const tplList = templatesFor(hasUser, hasAi);
const previewTexts = extractPreview(recs); // 模板卡缩微预览的素材(最多 2 组问答)
const modal = document.createElement('div');
modal.className = CLS.MODAL;
modal.innerHTML = `
选择导出内容
即将导出 ${recs.length} 条消息(提问 ${nUser} 条 · 回答 ${nAi} 条):
导出格式
`;
const q = (s) => modal.querySelector('.' + s);
const stepContent = q(CLS.STEP_CONTENT);
const stepTemplate = q(CLS.STEP_TEMPLATE);
const titleEl = q(CLS.TITLE);
const subEl = q(CLS.SUB);
const tplSub = modal.querySelector('.ds-md-tpl-sub');
const tplBox = modal.querySelector('.ds-md-template-options');
const rememberWrap = q(CLS.REMEMBER);
const rememberChk = modal.querySelector('.ds-md-chk-remember');
const actions = q(CLS.ACTIONS);
const reasoningChk = modal.querySelector('.ds-md-chk-reasoning');
const backBtn = q(CLS.BACK);
// 预填(来自上次「记住我的选择」)
reasoningChk.checked = prefs.includeReasoning;
rememberChk.checked = prefs.remember;
const readOpts = () => ({
includeReasoning: !!reasoningChk.checked,
templateId: null,
});
const persist = (templateId) => {
// 未勾选 = 用户明确表示「这次不要记住」→ 必须把既有记忆清掉。
// 只做「不写入」会让上一轮的 remember:true 继续生效,下次仍然预填(隐形状态)。
if (!rememberChk.checked) {
try { GM_setValue(STORAGE_MD_EXPORT_PREFS, ''); } catch (e) { /* 忽略 */ }
return;
}
const o = readOpts();
savePrefs({ includeReasoning: o.includeReasoning, templateId, remember: true });
};
const finish = (templateId) => {
const o = readOpts();
o.templateId = templateId;
o.format = 'md';
persist(templateId);
// 必须先快照选中键:stopSelection() 会 clear() 掉 selectedKeys
const snapshot = new Set(selectedKeys);
closeDialog();
stopSelection();
runExport(o, snapshot);
};
// JSON 是结构化数据,没有「内容模板」这一层 → 点格式即导出,不进步 2。
// 因此这里**不写偏好**:remember 开关在步 1 是隐藏的,落库等于让看不见的控件改写记忆。
const finishJson = () => {
const o = readOpts();
o.templateId = null;
o.format = 'json';
const snapshot = new Set(selectedKeys);
closeDialog();
stopSelection();
runExport(o, snapshot);
};
const showContentStep = (animate) => {
stepContent.style.display = '';
stepTemplate.style.display = 'none';
titleEl.textContent = '选择导出内容';
subEl.textContent = `即将导出 ${recs.length} 条消息(提问 ${nUser} 条 · 回答 ${nAi} 条):`;
actions.hidden = false;
rememberWrap.hidden = true; // 步 1 还没选模板,无可记住的内容
modal.querySelector('.' + CLS.DIALOG).classList.remove('is-template-step');
// 仅「从模板步返回」时播滑入动画(首次打开不播)
if (animate) {
stepContent.classList.add('step-slide-back');
requestAnimationFrame(() => stepContent.classList.remove('step-slide-back'));
}
};
const showTemplateStep = () => {
stepContent.style.display = 'none';
stepTemplate.style.display = '';
titleEl.textContent = '选择内容模板';
if (tplSub) tplSub.textContent = '点击所需模板即可导出:';
actions.hidden = true;
rememberWrap.hidden = false;
modal.querySelector('.' + CLS.DIALOG).classList.add('is-template-step');
// 模板清单已按选中内容(是否只有提问)确定,与步 1 的选项无关
renderTemplates();
stepTemplate.classList.add('step-slide-in');
requestAnimationFrame(() => stepTemplate.classList.remove('step-slide-in'));
};
function renderTemplates() {
tplBox.innerHTML = '';
tplList.forEach(t => {
const card = document.createElement('button');
card.type = 'button';
card.className = CLS.TPL_CARD;
card.dataset.template = t.id;
if (t.id === prefs.templateId) card.classList.add('is-remembered');
// 结构:上半 = 132px 缩微预览,下半 = 名称 + 描述
const pv = document.createElement('div');
pv.className = 'ds-md-pv';
pv.innerHTML = previewHtml(t.id, previewTexts);
const info = document.createElement('div');
info.className = 'ds-md-tpl-info';
const nm = document.createElement('span');
nm.className = 'ds-md-tpl-name';
nm.textContent = t.name;
const ds = document.createElement('span');
ds.className = 'ds-md-tpl-desc';
ds.textContent = t.desc;
info.appendChild(nm);
info.appendChild(ds);
card.appendChild(pv);
card.appendChild(info);
card.addEventListener('click', () => finish(t.id));
tplBox.appendChild(card);
});
}
// 格式卡:Markdown 进步 2 选内容模板;JSON 就地导出
modal.querySelectorAll('.' + CLS.FMT_BTN).forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.format === 'json') { finishJson(); return; }
showTemplateStep();
});
});
backBtn.addEventListener('click', () => showContentStep(true));
q(CLS.CANCEL_DLG).addEventListener('click', () => { closeDialog(); }); // 回到选择态(不退出)
modal.querySelector('.ds-md-dlg-close').addEventListener('click', () => { closeDialog(); });
modal.addEventListener('click', (e) => { if (e.target === modal) closeDialog(); });
const onKey = (e) => { if (e.key === 'Escape') closeDialog(); };
document.addEventListener('keydown', onKey);
dialogCleanup = () => document.removeEventListener('keydown', onKey);
showContentStep();
document.body.appendChild(modal);
dialogEl = modal;
return true;
}
function closeDialog() {
if (dialogCleanup) { try { dialogCleanup(); } catch (e) { /* 忽略 */ } dialogCleanup = null; }
if (dialogEl) { try { dialogEl.remove(); } catch (e) { /* 忽略 */ } dialogEl = null; }
}
async function exportSelected() {
if (guardSessionChange()) return false;
if (!selectedKeys.size) { showToast('请先选择要导出的消息'); return false; }
// 导出前确保已扫全(长对话下窗口外的消息只有扫过才有内容)
await ensureScanned();
// 图表态代码块切回代码视图并刷新快照 —— 否则 pre 为空,导出会丢代码
try {
if (needsCodeViewSwitch(document)) {
showNotice('正在切换代码视图…');
await ensureCodeView(document);
hideNotice();
rememberMessages();
}
} catch (e) { hideNotice(); }
if (!selectedKeys.size) { showToast('请先选择要导出的消息'); return false; }
return openDialog();
}
function runExport(o, selSet) {
const isJson = !!o && o.format === 'json';
const picked = selSet instanceof Set ? selSet : new Set(selectedKeys);
let r;
try {
r = isJson
? buildJson({ includeReasoning: o.includeReasoning, selectedKeys: picked })
: buildMarkdown({ includeReasoning: o.includeReasoning, templateId: o.templateId, selectedKeys: picked });
} catch (e) {
console.error('[deepseektool] 导出失败:', e);
showToast('导出失败:' + (e && e.message ? e.message : '未知错误'));
return false;
}
if (!r.ok) { showToast(r.error); return false; }
if (isJson) {
download(r.text, buildFilename('json'), 'application/json;charset=utf-8');
// 把实际生效的选项写进提示 —— 「怎么少了内容」这类疑问必须一眼可见,而不是静默发生
let jtip = `已导出 ${r.count} 条消息为 JSON`;
if (o.includeReasoning) {
jtip += r.reasoningFound
? '(含思考过程,见 reasoning 字段)'
: ';未发现「已思考」内容,需先在页面上展开过才会被导出';
}
if (r.pending < 0) jtip += ';对话较长,建议先点「全选」扫描后再导出';
showToast(jtip, (o.includeReasoning && !r.reasoningFound) || r.pending < 0 ? 5200 : 4200);
return true;
}
download(r.md, buildFilename('md'));
// 把实际生效的选项写进提示 —— 「怎么少了内容」这类疑问必须一眼可见,而不是静默发生
let tip = `已导出 ${r.count} 条消息为 Markdown`;
const extras = [];
if (o.includeReasoning) extras.push('含思考过程');
const tplName = templateNameOf(o.templateId);
if (tplName) extras.push('模板:' + tplName);
if (extras.length) tip += `(${extras.join(' · ')})`;
// 勾了「导出思考过程」但一条都没取到 → 明确告知原因,不要静默无声
if (o.includeReasoning && !r.reasoningFound) {
tip += ';未发现「已思考」内容,需先在页面上展开过才会被导出';
}
if (r.pending < 0) tip += ';对话较长,建议先点「全选」扫描后再导出';
showToast(tip, (o.includeReasoning && !r.reasoningFound) || r.pending < 0 ? 5200 : 4200);
return true;
}
// 菜单命令入口:直接进入勾选态
function beginExportFlow() {
if (!mdExportEnabled) { showToast('对话导出已关闭,请在「脚本设置 → 对话导出」中开启'); return false; }
return startSelection();
}
return {
beginExportFlow, startSelection, stopSelection, exportSelected, clearPrefs,
buildMarkdown, buildJson, isSelecting: () => selectionActive,
SEL, ATTR,
};
})();
// ==================== 代码块导出为图片 ====================
// 把单个代码块渲染成「窗口卡片」样式的图片:背景 + 窗口 chrome + 代码体(可选行号)。
//
// 关键设计(依据真实页面实测,见 .workbuddy/research/code-export-image-trace.md):
// 1. 官网高亮是 Prism token class(无内联色)→「浅色底 / 深色底」通过注入 CSS 覆盖实现;
// 2. 官网 .token / .md-code-block 规则读不到 cssRules → 必须自建完整样式与 token 色板;
// 3. 折叠态只是 max-height/overflow 的视觉裁剪,内容完整留在 DOM → 克隆后清样式即可拿全文,
// **绝不在原 DOM 上改动**(与表格导出同一条「克隆 + 隔离」原则);
// 4. 官网 pre 计算样式为 white-space:pre-wrap,但实测最宽行 829px < 内容宽 858px、未触发折行;
// 导出统一强制 white-space:pre(不折行,卡片按最长行自适应宽),逻辑行 = 视觉行,行号 counter 1:1;
// 5. 复用既有 html2canvas(@require)与「离屏 iframe 隔离」渲染手法,零新增依赖 / 零新增权限。
const codeImageUnit = (() => {
const MONO_STACK = 'Menlo, Monaco, Consolas, "Cascadia Mono", "Ubuntu Mono", "DejaVu Sans Mono", "Liberation Mono", "JetBrains Mono", "Fira Code", Courier, monospace';
// 背景预设:渐变 / 纯色 / 透明
const BG_PRESETS = [
{ id: 'grad-indigo', css: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', label: '靛蓝渐变' },
{ id: 'grad-sunset', css: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', label: '日落渐变' },
{ id: 'grad-mint', css: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', label: '薄荷渐变' },
{ id: 'grad-ocean', css: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', label: '海洋渐变' },
{ id: 'grad-night', css: 'linear-gradient(135deg, #232526 0%, #414345 100%)', label: '深空渐变' },
{ id: 'solid-gray', css: '#e9edf2', label: '浅灰' },
{ id: 'solid-dark', css: '#1e1e24', label: '墨黑' },
{ id: 'transparent', css: 'transparent', label: '透明' },
];
const bgCssOf = (id) => {
const hit = BG_PRESETS.filter(p => p.id === id)[0];
return hit ? hit.css : BG_PRESETS[0].css;
};
// 浅色 / 深色代码色板。
// 取值来源:官网真机实测(comment / keyword / function / operator / constant / punctuation)
// + 标准 One Light / One Dark 对剩余 token 类型的约定。
// ⚠️ 这份色板只负责「导出底色与页面当前底色**不一致**」的跨主题场景;
// 底色一致时以 harvestTokenColors() 采到的页面实测色为准(见 buildShotCss)。
const THEME_LIGHT = {
name: 'light', bg: '#f3f4f6', text: '#383a42',
comment: '#a0a1a7', prolog: '#a0a1a7', cdata: '#a0a1a7',
keyword: '#a626a4', boolean: '#b76b01',
string: '#50a14f', 'template-string': '#50a14f', 'template-punctuation': '#50a14f',
'attr-value': '#50a14f', char: '#50a14f', regex: '#50a14f', variable: '#50a14f', inserted: '#50a14f',
number: '#b76b01', constant: '#b76b01', symbol: '#b76b01', 'attr-name': '#b76b01',
'class-name': '#b76b01', deleted: '#b76b01',
tag: '#e45649', selector: '#e45649',
function: '#4078f2', 'function-variable': '#4078f2', atrule: '#4078f2', operator: '#4078f2',
property: '#e45649', 'literal-property': '#e45649',
url: '#0184bc',
parameter: '#383a42', punctuation: '#383a42',
};
const THEME_DARK = {
name: 'dark', bg: '#282c34', text: '#abb2bf',
comment: '#5c6370', prolog: '#5c6370', cdata: '#5c6370',
keyword: '#c678dd',
string: '#98c379', 'template-string': '#98c379', 'template-punctuation': '#98c379',
'attr-value': '#98c379', char: '#98c379', regex: '#98c379', variable: '#98c379',
inserted: '#98c379', selector: '#98c379', builtin: '#98c379',
number: '#d19a66', constant: '#d19a66', boolean: '#d19a66',
'attr-name': '#d19a66', 'class-name': '#d19a66', atrule: '#d19a66',
function: '#61afef', 'function-variable': '#61afef',
property: '#e06c75', 'literal-property': '#e06c75', tag: '#e06c75', symbol: '#e06c75',
deleted: '#e06c75', important: '#e06c75',
operator: '#56b6c2', url: '#56b6c2',
parameter: '#abb2bf', punctuation: '#abb2bf',
};
const DEFAULT_OPTS = {
bg: 'grad-indigo', window: 'macos', padding: 32, shadow: true,
fontSize: 13, lineHeight: 22, lineNumbers: false, scale: 2, theme: '',
};
// ---------- 样式偏好读写(记住上次选择) ----------
function loadPrefs() {
try {
if (codeImagePrefs) return typeof codeImagePrefs === 'string' ? JSON.parse(codeImagePrefs) : codeImagePrefs;
} catch (_) { /* 数据损坏则忽略,回落默认 */ }
return {};
}
function savePrefs(p) {
codeImagePrefs = p;
try { GM_setValue(STORAGE_CODE_IMAGE_PREFS, JSON.stringify(p)); } catch (_) { /* 忽略 */ }
}
// ---------- 语言提取(来自 banner 文案,排除操作按钮文字) ----------
const OP_LABEL = /^(复制|下载|展开|收起|编辑|运行|预览|导出|copy|download|expand|collapse|edit|run)$/i;
function extractLang(block) {
const banner = block.querySelector('.md-code-block-banner-wrap');
if (!banner) return '';
for (const sp of banner.querySelectorAll('span')) {
const s = String(sp.textContent || '').trim();
if (!s || s.length > 40 || OP_LABEL.test(s)) continue;
return s.split(/\s+/)[0].toLowerCase();
}
return '';
}
// ---------- 克隆清洗(绝不改原 DOM) ----------
function cloneCodeBlockPre(block) {
if (!block) return null;
const clone = block.cloneNode(true);
const stripSvg = (root) => {
[...root.children].forEach(c => { if (c.tagName && c.tagName.toLowerCase() === 'svg') c.remove(); });
};
// 整条 banner(语言标签 + 官网按钮 + 折叠/导出按钮)与装饰 svg 一并剔除
clone.querySelectorAll('.md-code-block-banner-wrap').forEach(el => el.remove());
clone.querySelectorAll('.ds-fold-btn, .ds-code-export-btn, .table-internal-buttons').forEach(el => el.remove());
clone.removeAttribute('data-fold-processed');
stripSvg(clone);
const pre = clone.querySelector('pre');
if (!pre) return null;
// 清折叠态(仅在克隆体上操作)
pre.style.maxHeight = '';
pre.style.overflow = '';
pre.style.display = '';
pre.classList.remove('ds-fold-preview');
delete pre.dataset.origMaxHeight;
delete pre.dataset.origOverflow;
delete pre.dataset.origDisplay;
stripSvg(pre);
return pre;
}
function resolveTheme(opts) {
if (opts.theme === 'light') return THEME_LIGHT;
if (opts.theme === 'dark') return THEME_DARK;
return document.body.classList.contains('dark') ? THEME_DARK : THEME_LIGHT;
}
const isPageDark = () => document.body.classList.contains('dark');
// ---------- 页面实测 token 颜色采集 ----------
// 为什么需要它:官网高亮色定义在跨域样式表 / CSS-in-JS 里,cssRules 读不到,
// 自建色板又只能覆盖「我们想得到的」token 类型 —— 实测真机 html 代码块用的是
// tag / attr-name / attr-value,一度全部落在色板之外,导致 1264/1270 个
// 本来有颜色的 token 在导出图上掉回纯文字色。
// 但「当前页面正在渲染的 token」其计算色一定读得到:就地按类名采集,
// 既不依赖任何外部定义,也能自动覆盖官网后续新增的 token 类型。
// 仅用于「导出底色 == 页面当前底色」;跨主题时仍走自建色板,否则明暗会串味。
const TOKEN_CLS_OK = /^[A-Za-z_][\w-]*$/;
const RESERVED_KEYS = ['name', 'bg', 'text'];
function harvestTokenColors(root) {
const map = {};
if (!root || !root.querySelectorAll) return map;
root.querySelectorAll('.token').forEach(el => {
const color = getComputedStyle(el).color;
if (!color) return;
String(el.className).split(/\s+/).forEach(c => {
if (!c || c === 'token' || RESERVED_KEYS.indexOf(c) >= 0) return;
if (!TOKEN_CLS_OK.test(c) || c in map) return;
map[c] = color;
});
});
return map;
}
// ---------- 组装合成 DOM(背景 / 卡片 / 窗口 chrome / 代码体) ----------
function buildShotDom(pre, opts, theme) {
const root = document.createElement('div');
root.className = 'ds-shot-root';
root.style.cssText = `display:inline-block;padding:${opts.padding}px;background:${bgCssOf(opts.bg)};`;
const card = document.createElement('div');
card.className = 'ds-shot-card';
// position:relative 是必需的,不是装饰:
// html2canvas@1.4.1 在分层渲染时,会把「未定位的 inline-block」元素的背景归到
// 靠后的绘制分组,导致卡片底色排到子元素背景之后 —— 卡片底色会把 macOS 三点
// 整块反盖掉(实测红点像素 424 → 0),且与代码长短无关。
// 让卡片成为定位元素即可把它归入 positioned 分组,保证「先卡片底色、后子元素」,
// 同时不改变任何布局与尺寸(实测节点盒与画布尺寸与修复前逐像素一致)。
card.style.cssText = 'display:inline-block;min-width:300px;border-radius:12px;overflow:hidden;position:relative;'
+ `background:${theme.bg};`
+ (opts.shadow ? 'box-shadow:0 14px 36px rgba(0,0,0,.30),0 3px 10px rgba(0,0,0,.18);' : '');
if (opts.window === 'macos') {
const bar = document.createElement('div');
bar.className = 'ds-shot-bar';
bar.style.cssText = 'display:flex;align-items:center;gap:8px;padding:12px 16px;';
['#ff5f57', '#febc2e', '#28c840'].forEach(c => {
const dot = document.createElement('span');
dot.style.cssText = `width:12px;height:12px;border-radius:50%;display:block;background:${c};`;
bar.appendChild(dot);
});
card.appendChild(bar);
}
const body = document.createElement('div');
body.className = 'ds-shot-body';
body.appendChild(pre);
card.appendChild(body);
root.appendChild(card);
return root;
}
// ---------- 组装样式(自建,不依赖官网规则) ----------
// liveColors:由 harvestTokenColors() 在**原 DOM** 上采到的「类名 → 计算色」。
// 只有「导出底色 == 页面当前底色」时才用它(完全还原官网观感);
// 跨主题时忽略,改用自建色板,否则浅色 token 会被搬到深色底上。
function buildShotCss(opts, theme, liveColors) {
const palette = Object.assign({}, theme);
if (liveColors && (theme.name === 'dark') === isPageDark()) {
Object.keys(liveColors).forEach(k => { palette[k] = liveColors[k]; });
}
const tokenRules = Object.keys(palette)
.filter(k => RESERVED_KEYS.indexOf(k) === -1)
.map(k => `.ds-shot-root .token.${k}{color:${palette[k]} !important;}`)
.join('');
const gutter = opts.lineNumbers ? `
.ds-shot-root pre{counter-reset:ds-shot-line;}
.ds-shot-root pre > span{counter-increment:ds-shot-line;}
.ds-shot-root pre > span::before{
content:counter(ds-shot-line);display:inline-block;min-width:2.1em;padding-right:1em;
text-align:right;opacity:.4;
}` : '';
return `
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{display:inline-block;}
.ds-shot-root{display:inline-block;}
.ds-shot-root pre{
margin:0;padding:16px;display:block;
font-family:${MONO_STACK};
font-size:${opts.fontSize}px;line-height:${opts.lineHeight}px;
color:${theme.text};background:transparent;
white-space:pre;overflow:visible;tab-size:4;
}
.ds-shot-root .token{color:${theme.text};}
${tokenRules}
${gutter}
`;
}
// ---------- 渲染为 canvas(离屏 iframe 隔离,复用既有表格导出手法) ----------
async function renderToCanvas(preEl, opts) {
if (!window.html2canvas) throw new Error('html2canvas 未加载');
const block = preEl.closest('.md-code-block') || preEl;
// 采色必须发生在**原 DOM** 上:克隆体已脱离文档,getComputedStyle 取不到值
const liveColors = harvestTokenColors(block);
const cleanPre = cloneCodeBlockPre(block);
if (!cleanPre) throw new Error('未找到代码内容');
const theme = resolveTheme(opts);
const css = buildShotCss(opts, theme, liveColors);
const dom = buildShotDom(cleanPre, opts, theme);
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:fixed;left:-99999px;top:0;width:1400px;height:900px;border:0;';
iframe.srcdoc = `${dom.outerHTML}`;
document.body.appendChild(iframe);
try {
await new Promise(resolve => { iframe.onload = resolve; setTimeout(resolve, 2000); });
const doc = iframe.contentDocument;
const node = doc && doc.querySelector('.ds-shot-root');
if (!node) throw new Error('渲染节点缺失');
return await window.html2canvas(node, { scale: opts.scale, backgroundColor: null, logging: false });
} finally {
setTimeout(() => iframe.remove(), 120);
}
}
// ---------- 输出 ----------
function canvasToBlob(canvas) {
return new Promise(res => canvas.toBlob(res, 'image/png'));
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 200);
}
const canCopyImage = () => !!(navigator.clipboard && navigator.clipboard.write && window.ClipboardItem);
function copyBlob(blob) {
return navigator.clipboard.write([new window.ClipboardItem({ 'image/png': blob })]);
}
function makeFilename(lang) {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
return `code-${lang || 'snippet'}-${ts}.png`;
}
// ---------- 弹窗 ----------
let _activeModal = null;
let _busy = false;
function closeDialog() {
if (_activeModal) {
if (_activeModal._previewHost && _activeModal._previewHost.shadowRoot) _activeModal._previewHost.shadowRoot.innerHTML = '';
_activeModal.remove();
_activeModal = null;
}
document.removeEventListener('keydown', onDialogKeydown, true);
}
function onDialogKeydown(e) {
if (e.key === 'Escape') { e.stopPropagation(); closeDialog(); }
}
function openDialog(preEl) {
if (!codeExportEnabled) { showToast('代码块导出已关闭,请在「脚本设置 → 代码块导出」中开启'); return; }
const block = preEl.closest('.md-code-block');
if (!block) return;
closeDialog();
const lang = extractLang(block);
const opts = Object.assign({}, DEFAULT_OPTS, loadPrefs());
if (!opts.theme) opts.theme = document.body.classList.contains('dark') ? 'dark' : 'light';
const modal = document.createElement('div');
modal.className = 'ds-ci-modal';
const dialog = document.createElement('div');
dialog.className = 'ds-ci-dialog';
// ----- 头部 -----
const header = document.createElement('div');
header.className = 'ds-ci-header';
const title = document.createElement('h3');
title.className = 'ds-ci-title';
title.textContent = '导出代码为图片';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'ds-ci-close';
closeBtn.setAttribute('aria-label', '关闭');
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', closeDialog);
header.appendChild(title);
header.appendChild(closeBtn);
// ----- 主体 -----
const body = document.createElement('div');
body.className = 'ds-ci-body';
const previewWrap = document.createElement('div');
previewWrap.className = 'ds-ci-preview-wrap';
body.appendChild(previewWrap);
const options = document.createElement('div');
options.className = 'ds-ci-options';
body.appendChild(options);
// ----- 页脚 -----
const footer = document.createElement('div');
footer.className = 'ds-ci-footer';
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'ds-ci-btn';
copyBtn.textContent = '复制图片';
const dlBtn = document.createElement('button');
dlBtn.type = 'button';
dlBtn.className = 'ds-ci-btn primary';
dlBtn.textContent = '下载 PNG';
footer.appendChild(copyBtn);
footer.appendChild(dlBtn);
dialog.appendChild(header);
dialog.appendChild(body);
dialog.appendChild(footer);
modal.appendChild(dialog);
// ----- 选项控件辅助 -----
const field = (labelText, controlEl, wide) => {
const f = document.createElement('div');
f.className = 'ds-ci-field' + (wide ? ' is-wide' : '');
const l = document.createElement('div');
l.className = 'ds-ci-label';
l.textContent = labelText;
f.appendChild(l);
f.appendChild(controlEl);
return f;
};
const seg = (items, current, onPick) => {
const wrap = document.createElement('div');
wrap.className = 'ds-ci-seg';
items.forEach(it => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ds-ci-seg-btn' + (it.value === current ? ' is-active' : '');
b.textContent = it.label;
b.addEventListener('click', () => {
wrap.querySelectorAll('.ds-ci-seg-btn').forEach(x => x.classList.remove('is-active'));
b.classList.add('is-active');
onPick(it.value);
});
wrap.appendChild(b);
});
return wrap;
};
const slider = (min, max, step, value, unit, onInput) => {
const wrap = document.createElement('div');
wrap.className = 'ds-ci-range';
const input = document.createElement('input');
input.type = 'range';
input.min = min; input.max = max; input.step = step; input.value = value;
const val = document.createElement('span');
val.className = 'ds-ci-range-val';
val.textContent = value + (unit || '');
input.addEventListener('input', () => { val.textContent = input.value + (unit || ''); onInput(Number(input.value)); });
wrap.appendChild(input);
wrap.appendChild(val);
return wrap;
};
const toggle = (labelText, checked, onChange) => {
const l = document.createElement('label');
l.className = 'ds-ci-switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!checked;
input.addEventListener('change', () => onChange(input.checked));
const s = document.createElement('span');
s.textContent = labelText;
l.appendChild(input);
l.appendChild(s);
return l;
};
// ----- 选项(8 项) -----
// 1) 背景(色板,整行)
const swRow = document.createElement('div');
swRow.className = 'ds-ci-sw-row';
BG_PRESETS.forEach(p => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ds-ci-sw' + (p.id === opts.bg ? ' is-active' : '') + (p.id === 'transparent' ? ' is-transparent' : '');
b.title = p.label;
b.setAttribute('aria-label', '背景:' + p.label);
if (p.id !== 'transparent') b.style.background = p.css;
b.addEventListener('click', () => {
swRow.querySelectorAll('.ds-ci-sw').forEach(x => x.classList.remove('is-active'));
b.classList.add('is-active');
opts.bg = p.id;
refreshPreview();
});
swRow.appendChild(b);
});
options.appendChild(field('背景', swRow, true));
// 2) 窗口样式 / 8) 底色
options.appendChild(field('窗口样式', seg(
[{ value: 'macos', label: 'macOS 三点' }, { value: 'none', label: '无框' }],
opts.window, v => { opts.window = v; refreshPreview(); }
)));
options.appendChild(field('代码底色', seg(
[{ value: 'light', label: '浅色底' }, { value: 'dark', label: '深色底' }],
opts.theme, v => { opts.theme = v; refreshPreview(); }
)));
// 3) 内边距 / 5) 字号
options.appendChild(field('内边距', slider(16, 80, 2, opts.padding, 'px', v => { opts.padding = v; refreshPreview(); })));
options.appendChild(field('字号', slider(12, 18, 1, opts.fontSize, 'px', v => { opts.fontSize = v; refreshPreview(); })));
// 行高 / 7) 导出倍率
options.appendChild(field('行高', slider(16, 30, 1, opts.lineHeight, 'px', v => { opts.lineHeight = v; refreshPreview(); })));
options.appendChild(field('导出倍率', seg(
[{ value: 1, label: '1x' }, { value: 2, label: '2x' }, { value: 3, label: '3x' }],
opts.scale, v => { opts.scale = v; refreshPreview(); }
)));
// 4) 投影 / 6) 行号
const toggles = document.createElement('div');
toggles.className = 'ds-ci-toggles';
toggles.appendChild(toggle('投影', opts.shadow, v => { opts.shadow = v; refreshPreview(); }));
toggles.appendChild(toggle('行号', opts.lineNumbers, v => { opts.lineNumbers = v; refreshPreview(); }));
options.appendChild(field('其他', toggles, true));
// ----- 预览(Shadow DOM 隔离,与导出共用同一套样式) -----
let previewTimer = null;
function refreshPreview() {
clearTimeout(previewTimer);
previewTimer = setTimeout(() => {
try {
renderPreview(previewWrap, preEl, opts);
savePrefs(opts);
} catch (err) {
console.error('[代码导图] 预览失败:', err);
}
}, 30);
}
function renderPreview(host, srcPre, o) {
const theme = resolveTheme(o);
const liveBlock = srcPre.closest('.md-code-block') || srcPre;
const shadow = host.shadowRoot || host.attachShadow({ mode: 'open' });
shadow.innerHTML = '';
const style = document.createElement('style');
style.textContent = buildShotCss(o, theme, harvestTokenColors(liveBlock));
const cleanPre = cloneCodeBlockPre(liveBlock);
const dom = buildShotDom(cleanPre, o, theme);
const wrap = document.createElement('div');
wrap.style.cssText = 'display:inline-block;overflow:hidden;';
wrap.appendChild(dom);
shadow.appendChild(style);
shadow.appendChild(wrap);
requestAnimationFrame(() => {
const availW = Math.max(120, host.clientWidth - 36);
const w = dom.offsetWidth || 1;
const k = Math.min(1, availW / w);
dom.style.transformOrigin = 'top left';
dom.style.transform = `scale(${k})`;
wrap.style.width = Math.round(w * k) + 'px';
wrap.style.height = Math.round((dom.offsetHeight || 1) * k) + 'px';
});
}
// ----- 导出动作 -----
async function runExport(mode) {
if (_busy) return;
_busy = true;
copyBtn.disabled = true;
dlBtn.disabled = true;
const oldText = dlBtn.textContent;
dlBtn.textContent = '生成中…';
try {
const canvas = await renderToCanvas(preEl, opts);
const blob = await canvasToBlob(canvas);
if (!blob) throw new Error('无法生成图片数据');
if (mode === 'copy') {
await copyBlob(blob);
showToast('图片已复制到剪贴板');
} else {
downloadBlob(blob, makeFilename(lang));
showToast('图片已下载');
}
closeDialog();
} catch (err) {
console.error('[代码导图] 导出失败:', err);
showToast('导出失败:' + ((err && err.message) || '未知错误'));
} finally {
_busy = false;
copyBtn.disabled = false;
dlBtn.disabled = false;
dlBtn.textContent = oldText;
}
}
dlBtn.addEventListener('click', () => runExport('download'));
copyBtn.addEventListener('click', () => runExport('copy'));
if (!canCopyImage()) { copyBtn.disabled = true; copyBtn.title = '当前环境不支持复制图片'; }
document.body.appendChild(modal);
_activeModal = modal;
_activeModal._previewHost = previewWrap;
modal.addEventListener('click', (e) => { if (e.target === modal) closeDialog(); });
document.addEventListener('keydown', onDialogKeydown, true);
refreshPreview();
}
return { open: openDialog };
})();
// ==================== 统一 DOM 监听(合并多个 observer,添加节流) ====================
let _domObserver = null;
function observeDOM() {
if (_domObserver) return;
_domObserver = new MutationObserver(mutations => {
let hasNewCodeBlocks = false;
let hasNewTables = false;
let hasNewThinking = false;
let folderNeedsRescan = false; // 对话文件夹:出现新的会话链接/三点菜单时刷新
for (const m of mutations) {
if (m.type !== 'childList' || !m.addedNodes.length) continue;
for (const node of m.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// 代码块检测
if (!hasNewCodeBlocks) {
if (node.matches && node.matches('pre')) { hasNewCodeBlocks = true; }
else if (node.querySelectorAll) {
if (node.querySelector('pre')) hasNewCodeBlocks = true;
// 只查直接子级 pre,深度遍历留给具体处理
}
}
// 表格检测(含增量行/列,防范流式输出中仅新增 tr/td/th 的情况)
if (!hasNewTables) {
if (node.matches && node.matches('table,tbody,thead,tfoot,tr,td,th,.ds-markdown')) hasNewTables = true;
else if (node.querySelectorAll && (node.querySelector('table') || node.querySelector('.ds-markdown'))) hasNewTables = true;
}
// 思考区域检测(排除代码块内的文本)
if (!hasNewThinking && autoCollapseThinking) {
if (node.closest && node.closest('pre, .md-code-block')) continue;
if (node.classList && node.classList.contains('ds-think-content')) {
hasNewThinking = true;
} else if (node.querySelectorAll && node.querySelector('.ds-think-content')) {
hasNewThinking = true;
}
}
// 对话文件夹:用于纠偏官网虚拟列表/菜单复用等不一定会 emit 匹配文件的情况,与原独立脚本一致、
// 使用宽触发——任何 ELEMENT 新增都计划重扫。schedule 自带 120ms 节流,且 ensurePanel/refreshTags/
// applyHiding/标签注入都是幂等操作(不产生新的 childList),面板自身的插入也只多触发一次自稳 tick,不会自循环。
if (folderManagerEnabled && !folderNeedsRescan) folderNeedsRescan = true;
}
}
if (hasNewCodeBlocks) {
document.querySelectorAll('pre').forEach(pre => {
if (!pre.hasAttribute(processedAttr)) addFoldButtonToCodeBlock(pre);
});
}
if (hasNewTables) scheduleTableProcess();
if (hasNewThinking) setTimeout(processAllThinkingSections, 150);
if (folderNeedsRescan) folderUnit.schedule();
});
_domObserver.observe(document.body, { childList: true, subtree: true });
}
// ==================== 初始化 ====================
function init() {
applyTableThemeClass(tableThemeMode);
applyTableHeadAlign(tableHeadAlign);
applyWideScreen(wideScreen);
setTableButtonsAlways(tableButtonsAlways);
applyCodeBlockBg(codeBgEnhance, codeBgLevel);
cleanupLegacyWrappers();
deduplicateButtons();
processAllExistingCodeBlocks();
processAllTables();
if (autoCollapseThinking) {
setupThinkContentHiding();
processAllThinkingSections();
}
if (folderManagerEnabled) {
folderUnit.on();
folderUnit.schedule(); // 页面加载即应用(不等会话链接出现前的首轮 DOM 变化)
}
observeDOM();
// resize 节流处理表格:窗口尺寸变化同样会改变可用宽度,必须让表格重新落宽
window.addEventListener('resize', () => {
clearTimeout(window._resizeFix);
window._resizeFix = setTimeout(() => {
invalidateTableLayout();
}, 100);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();