Sleazy Fork is available in English.
X岛-EX 网页端增强,移动端般的浏览体验:快捷切换饼干-发送前二次确认 / 添加页首页码 / 关闭图片水印 / 预览真实饼干 / 隐藏无标题-无名氏-版规 / 显示外部图床 / 自动刷新饼干 toast提示 / 无缝翻页-自动翻页 / 默认原图+控件 / 新标签打开串 / 优化引用弹窗 / 拓展引用格式 / 当页回复编号 / 扩展坞增强 / 拦截回复中间页 / 颜文字拓展 / 高亮PO主 / 发串UI调整 / 『分组标记饼干』 / 『屏蔽饼干』 / 『只看饼干』 / 『屏蔽关键词』- 隐藏-折叠 / 增强X岛匿名版 / 板块页快速回复 / 展开板块页长串 / 野生搜索酱 / unvcode-零宽空格模式 / 侧边栏收起 / 图片显示模式 / 图片自动压缩-非法图像格式(无GCT)GIF重编码 / 链接自动识别 / 使用数据-设置项-导入导出-剪贴板文件 / 常用串 / 浏览历史 / 发言历史 / 移动端订阅 / 阅图模式 。
// ==UserScript== // @name X岛-EX // @namespace https://github.com/SayaGoodBye/nmbxd-EX // @version 4.3.0 // @description X岛-EX 网页端增强,移动端般的浏览体验:快捷切换饼干-发送前二次确认 / 添加页首页码 / 关闭图片水印 / 预览真实饼干 / 隐藏无标题-无名氏-版规 / 显示外部图床 / 自动刷新饼干 toast提示 / 无缝翻页-自动翻页 / 默认原图+控件 / 新标签打开串 / 优化引用弹窗 / 拓展引用格式 / 当页回复编号 / 扩展坞增强 / 拦截回复中间页 / 颜文字拓展 / 高亮PO主 / 发串UI调整 / 『分组标记饼干』 / 『屏蔽饼干』 / 『只看饼干』 / 『屏蔽关键词』- 隐藏-折叠 / 增强X岛匿名版 / 板块页快速回复 / 展开板块页长串 / 野生搜索酱 / unvcode-零宽空格模式 / 侧边栏收起 / 图片显示模式 / 图片自动压缩-非法图像格式(无GCT)GIF重编码 / 链接自动识别 / 使用数据-设置项-导入导出-剪贴板文件 / 常用串 / 浏览历史 / 发言历史 / 移动端订阅 / 阅图模式 。 // @author XY // @match https://*.nmbxd1.com/* // @match https://*.nmbxd.com/* // @match https://nmb-search.166666666.xyz/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @grant GM_xmlhttpRequest // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addStyle // @grant unsafeWindow // @connect nmbxd1.com // @connect www.nmbxd1.com // @connect nmbxd.com // @connect www.nmbxd.com // @connect nmb-search.166666666.xyz // @connect image.nmb.best // @connect api.nmb.best // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net // @connect fastly.jsdelivr.net // @connect update.greasyfork.org // @connect scriptcat.org // @connect code.jquery.com // @connect unpkg.com // @connect * // 说明:* 通配允许 GM_xmlhttpRequest 访问任意域名,用于用户自定义 WebDAV 地址(无需手动逐域授权) // @require https://code.jquery.com/jquery-3.6.0.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/lib/index.js // @require https://unpkg.com/[email protected]/UPNG.js // @icon https://image.nmb.best/image/2026-06-03/6a1fcea41fad3.png // @icon64 https://image.nmb.best/image/2026-06-03/6a1fced8e0e64.png // @license WTFPL // @changelog ### 新增\n\n- 新增导出功能,支持将串导出为Markdown/TXT文件\n- 新增饼干计时器,发送回复后提示当前饼干冷却时间\n- 新增被屏蔽串内的临时显示功能\n\n### 优化\n\n- 发送消息前对图像大小/格式进行预检,提前将图像压缩大小至2048KB以内或转化为合法格式之后再提交\n- 打开浏览历史/发言历史面板时自动聚焦搜索\n- 性能优化,减少在写入发言历史/发送消息后局部刷新的场景中发生的卡顿\n- 正文/标题/名称/E-mai拼音组词过程中不计算字数\n\n### 修复\n\n- 修复在弹出二次确认饼干时弹出“可能提交失败”toast提示的问题\n- 修复在部分场景下没有拓宽带图消息的问题\n // @note 特别感谢:icon由9HrD12x设计并绘制 >>No.68765505 // @note 致谢:切饼代码移植自[XD-Enhance](https://greasyfork.org/zh-CN/scripts/438164-xd-enhance) // @note 致谢:外部图床代码二改自[显示x岛图片链接指向的图片](https://greasyfork.org/zh-CN/scripts/546024-%E6%98%BE%E7%A4%BAx%E5%B2%9B%E5%9B%BE%E7%89%87%E9%93%BE%E6%8E%A5%E6%8C%87%E5%90%91%E7%9A%84%E5%9B%BE%E7%89%87) // @note 致谢:完整移植[增强x岛匿名版](https://greasyfork.org/zh-CN/scripts/513156-%E5%A2%9E%E5%BC%BAx%E5%B2%9B%E5%8C%BF%E5%90%8D%E7%89%88) // @note 致谢:部分功能移植自[X岛-揭示板的增强型体验](https://greasyfork.org/zh-CN/scripts/497875-x%E5%B2%9B-%E6%8F%AD%E7%A4%BA%E6%9D%BF%E7%9A%84%E5%A2%9E%E5%BC%BA%E5%9E%8B%E4%BD%93%E9%AA%8C#%E8%BF%9E%E6%8E%A5%E7%9B%B4%E6%8E%A5%E8%B7%B3%E8%BD%AC) // @note 致谢:来自4sYbzEX的搜索服务[野生搜索酱](https://www.nmbxd.com/t/64792841) // @note 致谢:来自acVMxuv的[侧边栏优化](https://greasyfork.org/zh-CN/scripts/553143-x%E5%B2%9B%E4%BC%98%E5%8C%96%E5%B2%9B-%E4%BE%A7%E8%BE%B9%E6%A0%8F%E4%BC%98%E5%8C%96%E7%89%88) // @run-at document-start // ==/UserScript== /* global $, jQuery */ // 更新渠道 // @downloadURL https://update.greasyfork.org/scripts/531005/X%E5%B2%9B-EX.user.js // @updateURL https://update.greasyfork.org/scripts/531005/X%E5%B2%9B-EX.meta.js // @downloadURL https://scriptcat.org/scripts/code/6289/X%E5%B2%9B-EX.user.js // @updateURL https://scriptcat.org/scripts/code/6289/X%E5%B2%9B-EX.meta.js /* ==XDEX-EARLY-THEME-START== (独立于主 IIFE:首帧前即执行,不依赖 jQuery/GM 存储就绪) */ const XDEX_DARK_SESSION_KEY = 'xdexCustomDarkEnabled'; function xdexEarlyDarkEnabled() { try { const session = sessionStorage.getItem(XDEX_DARK_SESSION_KEY); if (session === 'true') return true; if (session === 'false') return false; } catch (e) {} try { return (GM_getValue('myScriptSettings', {}) || {}).enableCustomDarkTheme === 'dark'; } catch (e) { return false; } } (function earlyCustomDarkTheme() { try { const boot = () => { try { const rootEl = document.documentElement; if (!rootEl) return; const drMode = rootEl.getAttribute('data-darkreader-mode') || rootEl.getAttribute('data-darkreader-scheme'); if (drMode && drMode !== 'off') return; const extMarked = rootEl.classList.contains('xdex-custom-dark'); if (!extMarked && !xdexEarlyDarkEnabled()) return; rootEl.classList.add('xdex-custom-dark'); if (!document.getElementById('xdex-custom-theme')) { const style = document.createElement('style'); style.id = 'xdex-custom-theme'; style.textContent = 'html.xdex-custom-dark,html.xdex-custom-dark body{background-color:#28292a!important;color:#d9d0d0!important}' + ':root.xdex-custom-dark #h-content,:root.xdex-custom-dark #h-content *,:root.xdex-custom-dark .h-threads-item,:root.xdex-custom-dark .h-threads-item *,:root.xdex-custom-dark .h-threads-item-reply-main,:root.xdex-custom-dark .h-preview-box,:root.xdex-custom-dark .h-preview-box *,:root.xdex-custom-dark #h-menu,:root.xdex-custom-dark #h-menu *,:root.xdex-custom-dark #h-bottom-nav,:root.xdex-custom-dark .qp-quote,:root.xdex-custom-dark .qp-quote *,:root.xdex-custom-dark #h-ref-view,:root.xdex-custom-dark #h-ref-view *,:root.xdex-custom-dark .kaomoji-panel,:root.xdex-custom-dark .kaomoji-panel *,:root.xdex-custom-dark .h-post-form,:root.xdex-custom-dark .h-post-form *,:root.xdex-custom-dark .xdex-post-history-thread,:root.xdex-custom-dark .xdex-post-history-thread *{background-color:#28292a!important;color:#d9d0d0!important}' + ':root.xdex-custom-dark #h-menu{background-color:#28292a!important;color:#e04747!important}' + ':root.xdex-custom-dark font[color="#789922"]{color:#b5d06d!important}'; const mount = document.head || rootEl || document; mount.appendChild(style); if (!document.head) { const headObserver = new MutationObserver(() => { if (document.head) { headObserver.disconnect(); if (style.isConnected) document.head.appendChild(style); } }); headObserver.observe(rootEl, { childList: true }); } } } catch (e) {} }; if (!document.documentElement) { const rootMo = new MutationObserver(() => { if (document.documentElement) { rootMo.disconnect(); boot(); } }); rootMo.observe(document, { childList: true, subtree: true }); return; } boot(); } catch (e) {} })(); /* ==XDEX-EARLY-THEME-END== */ (function($){ 'use strict'; /* -------------------------------------------------- * tag 0. 通用与工具函数 * -------------------------------------------------- */ const VERSION = GM_info.script.version; const XDEX_SINGLETON_OWNER_DATASET_KEY = 'xdexSingletonOwner'; const XDEX_SINGLETON_WAIT_MS = 100; function getXDexRuntimeInfo(){ const declared = typeof globalThis !== 'undefined' ? globalThis.__xdexRuntime : null; if (declared && declared.kind === 'extension') { return Object.assign({ kind: 'extension' }, declared); } const scriptHandler = GM_info && (GM_info.scriptHandler || (GM_info.script && GM_info.script.handler)) || ''; return { kind: 'userscript', scriptHandler: scriptHandler || 'unknown' }; } (function earlyIconBtnStyle() { try { if (document.getElementById('xdex-icon-btn-global')) return; const style = document.createElement('style'); style.id = 'xdex-icon-btn-global'; style.textContent = '.xdex-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:1px solid var(--xdex-sp-border,#bfa58f);border-radius:8px;background:var(--xdex-sp-panel-bg,#F0E0D6);cursor:pointer;color:inherit;box-sizing:border-box;}' + '.xdex-icon-btn:hover{border-color:#00ffcc !important;color:#00ffcc !important;}' + '.xdex-icon-btn:active{border-color:#00b386 !important;color:#00b386 !important;}' + '.xdex-icon-btn svg{display:block;}' + '.xdex-clear-image-btn:hover{border-color:#EE0000 !important;color:#EE0000 !important;}' + '#btn_sp_fullExport_reset:hover{border-color:#EE0000 !important;color:#EE0000 !important;}' + '.xdex-reply-mode-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:1px solid var(--xdex-sp-border,#bfa58f);border-radius:8px;background:var(--xdex-sp-panel-bg,#F0E0D6);cursor:pointer;color:inherit;box-sizing:border-box;flex:0 0 auto;}' + '.xdex-reply-mode-btn:hover{border-color:#00ffcc !important;color:#00ffcc !important;}' + '.xdex-reply-mode-btn svg{display:block;}' + ':root.xdex-custom-dark .xdex-reply-mode-btn,:root.xdex-darkreader-active .xdex-reply-mode-btn{background:#2b2c2d;border-color:#5a4d42;color:#d9d0d0;}' + ':root.xdex-custom-dark .xdex-reply-mode-btn:hover,:root.xdex-darkreader-active .xdex-reply-mode-btn:hover{border-color:#00ffcc !important;color:#00ffcc !important;}' + '.xdex-reply-mode-btn.js-reset:hover{border-color:#EE0000 !important;color:#EE0000 !important;}' + ':root.xdex-custom-dark .xdex-reply-mode-btn.js-reset:hover,:root.xdex-darkreader-active .xdex-reply-mode-btn.js-reset:hover{border-color:#EE0000 !important;color:#EE0000 !important;}' + ':root.xdex-custom-dark .xdex-icon-btn,:root.xdex-darkreader-active .xdex-icon-btn{background:#2b2c2d;border-color:#555;color:#d9d0d0;}' + ':root.xdex-custom-dark .qp-overlay-quote .h-threads-info .h-threads-info-title{color:#e85248 !important;}' + ':root.xdex-custom-dark .qp-overlay-quote .h-threads-item-reply-main{background:transparent !important;}' + ':root.xdex-custom-dark .qp-overlay-quote .h-threads-info,:root.xdex-custom-dark .qp-overlay-quote .h-threads-info *,:root.xdex-custom-dark .qp-overlay-quote .h-threads-content,:root.xdex-custom-dark .qp-overlay-quote .h-threads-content *{color:#d9d0d0 !important;}' + ':root.xdex-custom-dark .qp-overlay-quote .h-threads-tips.uk-text-danger{color:#e85248 !important;}' + ':root.xdex-custom-dark .qp-overlay-quote font[color="#789922"]{color:#b5d06d !important;}' + ':root.xdex-custom-dark .qp-overlay-quote a,:root.xdex-custom-dark .qp-overlay-quote .h-threads-content a,:root.xdex-custom-dark .qp-overlay-quote .h-threads-info a{color:#3e8eec !important;}' + ':root.xdex-custom-dark .kaomoji-item:hover{background:#3a3d40 !important;}' + '@keyframes xdex-icon-sync-spin{to{transform:rotate(360deg)}}' + '.xdex-icon-loading .xdex-icon-sync{animation:xdex-icon-sync-spin .8s linear infinite}' + '.xdex-board-refresh-btn:disabled{opacity:.55;cursor:default}' + '.xdex-edit-default-btn{display:inline-flex;align-items:center;justify-content:center;padding:0;border:none;background:none;cursor:pointer;color:inherit;flex-shrink:0;vertical-align:middle;margin-right:2px;}' + '.xdex-edit-default-btn:hover{color:#00ffcc !important;}' + '#xdex-image-viewer.xdex-iv-no-sep .xv-page-separator{display:none !important;}' + ':root.xdex-custom-dark .kaomoji-item.kaomoji-active{background:#3a3d40 !important;}'; (document.head || document.documentElement).appendChild(style); console.log('[xdex-theme-early] icon-btn global style injected'); } catch (e) {} })(); function shouldExitForXDexSingleton(runtimeInfo){ const root = document.documentElement; const owner = root && root.dataset ? root.dataset[XDEX_SINGLETON_OWNER_DATASET_KEY] : ''; return owner === 'extension' && (!runtimeInfo || runtimeInfo.kind !== 'extension'); } function cat_version(){ console.log('[version]:', VERSION); } const XDEX_RUNTIME = getXDexRuntimeInfo(); function getXDexGmStorageReady(){ const ready = typeof globalThis !== 'undefined' ? globalThis.__xdexGmStorageReady : null; return ready && typeof ready.then === 'function' ? ready : Promise.resolve(); } const XDEX_GM_STORAGE_READY = getXDexGmStorageReady(); function scheduleXDexStartup(){ if (shouldExitForXDexSingleton(XDEX_RUNTIME)) return; const startAfterStorageReady = () => { if (shouldExitForXDexSingleton(XDEX_RUNTIME)) return; startXDexRuntime(); }; if (XDEX_RUNTIME.kind !== 'extension') { setTimeout(() => { if (shouldExitForXDexSingleton(XDEX_RUNTIME)) return; XDEX_GM_STORAGE_READY.then(() => { startAfterStorageReady(); }, () => { startAfterStorageReady(); }); }, XDEX_SINGLETON_WAIT_MS); return; } XDEX_GM_STORAGE_READY.then(() => { startAfterStorageReady(); }, () => { startAfterStorageReady(); }); } function startXDexRuntime(){ cat_version(); console.log('[runtime]:', XDEX_RUNTIME.kind, XDEX_RUNTIME); function gmRequest(url, responseType = 'text', headers = null) { return new Promise((resolve, reject) => { const request = { method: 'GET', url, responseType, onload: (resp) => { if (resp.status >= 200 && resp.status < 300) { resolve(resp); } else { reject(new Error(`HTTP ${resp.status} ${url}`)); } }, onerror: () => reject(new Error(`Request failed: ${url}`)), ontimeout: () => reject(new Error(`Request timeout: ${url}`)) }; if (headers) request.headers = headers; GM_xmlhttpRequest(request); }); } const toastQueue = []; let isShowing = false; let refreshStatusGeneration = 0; function beginRefreshStatus() { refreshStatusGeneration += 1; return refreshStatusGeneration; } // ============ 页面类型 / 页码通用工具(PageType) ============ const PageType = { getPathname() { return location.pathname || ''; }, isThreadPage(loose) { if (/^\/Forum\/po\/id\/\d+/.test(this.getPathname())) return true; const re = loose === false ? /^\/t\/\d{6,8}(?:\/|$)/ : /^\/t\/\d{4,}(?:\/|$)/; return re.test(this.getPathname()); }, isPOThreadPage() { return /^\/Forum\/po\/id\/\d+/.test(this.getPathname()); }, isBoardPage() { return /^\/f\//.test(this.getPathname()); }, isTimelinePage() { return /^\/Forum\/timeline\/id\/\d+/.test(this.getPathname()); }, isTimelineAnyPage() { return /\/Forum\/timeline\//i.test(this.getPathname()); }, isTimelineLikePage() { return /\/Forum\/timeline\/id\/\d+/.test(this.getPathname()) || /\/timeline|\/feed/.test(this.getPathname()); }, isBoardOrTimelinePage() { return this.isBoardPage() || this.isTimelinePage(); }, isSpecialBoard() { return /^\/f\/(?:值班室|测试)(?:\/|$)/.test(decodeURIComponent(this.getPathname())); }, getThreadId(loose) { const p = this.getPathname(); const re = loose === false ? /^\/t\/(\d{6,8})(?:\/|$)/ : /^\/t\/(\d{4,})(?:\/|$)/; const m = p.match(re) || p.match(/^\/Forum\/po\/id\/(\d+)/); return m ? m[1] : ''; }, getPageNum() { // ?page= 优先,再回退路径 /page/N(与多数调用点语义一致) const q = parseInt(new URL(location.href, location.origin).searchParams.get('page') || '', 10); if (q > 0) return q; const m = this.getPathname().match(/\/page\/(\d+)(?:\.html)?$/); return m ? Math.max(1, parseInt(m[1], 10)) : 1; }, getPathPageNum() { // 路径 /page/N 优先,再回退 ?page=(阅图模式语义) const m = this.getPathname().match(/\/page\/(\d+)(?:\.html)?$/); if (m) return Math.max(1, parseInt(m[1], 10)); const q = parseInt(new URL(location.href, location.origin).searchParams.get('page') || '', 10); return q > 0 ? q : 1; }, getTimelineInfo() { const m = this.getPathname().match(/^\/Forum\/timeline\/id\/(\d+)(?:\/page\/(\d+)(?:\.html)?)?/i); return m ? { id: m[1], page: m[2] ? parseInt(m[2], 10) : 1 } : null; } }; function isCurrentRefreshStatus(generation) { return generation === refreshStatusGeneration; } function showRefreshStatus(msg, duration = 900, type = '') { // type='error':红框 + 驻留;type='hold':无红框 + 驻留(如「无更新」需留观但非错误); // 复用 refresh-status 节点,后续任何刷新状态提示(正在刷新/已更新)会自动顶掉它 return toast(msg, duration, { queue: false, key: 'refresh-status', type }); } function toast(msg, duration = 1800, options = {}) { if (options.type === 'error' || options.type === 'hold') { // ★ 错误/长驻提示强制走立即通道(不排队):error 红框驻留,hold 无红框驻留(见 showImmediateToast) showImmediateToast(msg, duration, options.key, options.type); return; } if (options.queue === false) { showImmediateToast(msg, duration, options.key); return; } toastQueue.push({ msg, duration }); if (!isShowing) showNextToast(); } function showNextToast() { if (toastQueue.length === 0) { isShowing = false; return; } isShowing = true; const { msg, duration } = toastQueue.shift(); console.log('[toast]', msg); // ✅ 每次创建一个新的 toast 节点 // 多行支持:white-space:pre-wrap 保留 \n 与缩进(同时仍自动换行长行); // 文本一律走 .text() 转义,避免消息里的 < > 被当成 HTML(此前是裸插值,且全仓无调用方传 HTML) const $t = $(`<div class="ae-toast" style=" position:fixed;top:10px;left:50%;transform:translateX(-50%); background:rgba(0,0,0,.75);color:#fff;padding:8px 18px; border-radius:5px;z-index:10099;display:none;font-size:14px; white-space:pre-wrap;max-width:min(92vw,600px);text-align:left;line-height:1.45; word-break:break-word;overflow-wrap:anywhere;"></div>`).text(msg); $('body').append($t); $t.fadeIn(240).delay(duration).fadeOut(240, () => { $t.remove(); // ✅ 动画结束后删除节点 showNextToast(); // ✅ 显示下一个 }); } function showImmediateToast(msg, duration = 900, key = 'default', type = '') { const safeKey = String(key || 'default').replace(/[^a-z0-9_-]/gi, '-'); if (safeKey === 'refresh-status') console.log('[toast]', msg); // 刷新状态提示同步输出到控制台(对齐通用 toast 路径) let $t = $(`#xdex-immediate-toast-${safeKey}`); if (!$t.length) { $t = $(`<div id="xdex-immediate-toast-${safeKey}" class="ae-toast" style=" position:fixed;top:10px;left:50%;transform:translateX(-50%); background:rgba(0,0,0,.75);color:#fff;padding:8px 18px; border-radius:5px;z-index:10099;display:none;font-size:14px; white-space:pre-wrap;max-width:min(92vw,600px);text-align:left;line-height:1.45; word-break:break-word;overflow-wrap:anywhere;"></div>`); $('body').append($t); } if (safeKey === 'refresh-status') { $t.css({ background: '#242424', boxShadow: '0 4px 16px rgba(0,0,0,.55)', fontWeight: 'bold', }); } const el = $t[0]; if (type === 'error') { // ★ 错误提示:红框 + 不自动消失,驻留到下次用户交互(点击/键盘/焦点切换),30s 保险上限 $t.css({ border: '1px solid #e74c3c', boxShadow: '0 0 0 1px rgba(231,76,60,.4), 0 4px 16px rgba(0,0,0,.55)', fontWeight: 'bold', }); el.__xdexToastError = true; } else if (type === 'hold') { // ★ 长驻提示:与 error 相同的驻留语义(无操作 30s / 操作后 5s 渐隐 / 同 key 顶掉 / 1s 武装期), // 但不加红框——用于「无更新」这类需要留观、又不应表现成错误的场景 el.__xdexToastError = true; } // ★ 复用节点时先清理上一代的驻留监听/计时器: // 「错误 toast 可被下一条同为 error 的 toast 顶掉」由此实现——顶掉一刻起旧监听即刻失效, // 若等到下次交互才清理会造成 document 捕获相监听器 + 30s 计时器永久泄漏 if (el.__xdexToastDismissCleanup) { el.__xdexToastDismissCleanup(); if (el.__xdexToastError) { try { console.log('[xdex error toast] 关闭原因: 被同 key 的新 toast 顶掉'); } catch (e) {} } el.__xdexToastDismissCleanup = null; } // 上一轮是错误、本轮不是 error/hold → 清除遗留的红框/加粗样式(refresh-status 专属样式见上方重新设置,无冲突) if (el.__xdexToastError && type !== 'error' && type !== 'hold') { $t.css({ border: '', boxShadow: '', fontWeight: '' }); if (safeKey === 'refresh-status') { $t.css({ background: '#242424', boxShadow: '0 4px 16px rgba(0,0,0,.55)', fontWeight: 'bold', }); } } el.__xdexToastError = (type === 'error' || type === 'hold'); const seq = (Number(el.__xdexImmediateToastSeq) || 0) + 1; el.__xdexImmediateToastSeq = seq; $t.stop(true, false).text(msg).show(); // 淡出被 stop(true,false) 打断时 style.opacity 会冻结在中间值,而 jQuery 的 .show() 不恢复不透明度, // 导致「上一条正在淡出时下发的新消息」整段偏淡甚至近乎不可见 → 显式复位 $t.css('opacity', 1); if (type === 'error' || type === 'hold') { dismissOnNextInteraction($t, el, seq); return $t; } $t.delay(duration).fadeOut(160, () => { if (el.__xdexImmediateToastSeq === seq) $t.remove(); }); return $t; } // ★ 错误 toast 语义 // 无操作驻留 30 秒(比普通 toast 的 1.8s 长得多,核心目的:提醒窗口拉长,避免一闪而过); // 用户发生键鼠操作后,进入 5 秒渐隐宽限(给足看清内容的时间,不是瞬间消失); // 挂载后 1 秒武装期内忽略交互(提交后的连续击键/点击不误关);同 key 新 error 顶掉旧 error。 // ★ 算「操作」的动作:左键/中键/右键按下、滚轮滚动、任意按键——均为对页面本身的操作; // 单纯移动鼠标、窗口切换、输入法切换不算(不监听 pointermove/focusin)。 const ERROR_TOAST_MAX_HOLD_MS = 30000; // 无操作驻留上限 const ERROR_TOAST_FADE_AFTER_INTERACT_MS = 5000; // 操作后的渐隐宽限 // ★ 挂载后 1 秒内的 pointerdown/keydown 不关闭 toast: // 「含有非法词语」等错误出现在提交动作之后,用户往往正在继续打字或点击(keydown/pointerdown // 紧随 toast 出现),若立即武装会把刚弹出的提示秒关——实测「消失得很快」的主因。 const ERROR_TOAST_ARM_DELAY_MS = 1000; function dismissOnNextInteraction($t, el, seq) { const dismiss = (reason) => { // ★ 消失原因诊断:无论哪条路径关闭,都输出原因(排查「红框很快消失」用) try { console.log(`[xdex error toast] 关闭原因: ${reason}`); } catch (e) {} $t.css('opacity', 1); // fadeOut 需要从完全不透明起步 $t.fadeOut(160, () => { if (el.__xdexImmediateToastSeq === seq) $t.remove(); }); }; let holdTimer = 0; let fadeTimer = 0; let armed = false; let armTimer = 0; let interacted = false; let pointerH, keyH, wheelH; // 保存「已挂载」的包裹引用,移除时才能精确匹配 const clearListeners = () => { if (pointerH) document.removeEventListener('pointerdown', pointerH, true); if (keyH) document.removeEventListener('keydown', keyH, true); if (wheelH) document.removeEventListener('wheel', wheelH, true); pointerH = keyH = wheelH = null; }; const cleanup = () => { clearListeners(); window.clearTimeout(holdTimer); window.clearTimeout(fadeTimer); window.clearTimeout(armTimer); // 节点级清理钩子已执行 → 清掉,避免下次复用重复触发 if (el.__xdexToastDismissCleanup === cleanup) el.__xdexToastDismissCleanup = null; }; // 交互后的 5 秒渐隐宽限:清监听与 30s 驻留,改由宽限计时器接管 const startFadeGrace = () => { clearListeners(); window.clearTimeout(holdTimer); fadeTimer = window.setTimeout(() => { dismiss(`${ERROR_TOAST_FADE_AFTER_INTERACT_MS / 1000} 秒宽限期满,渐隐`); cleanup(); }, ERROR_TOAST_FADE_AFTER_INTERACT_MS); }; // 传递 seq,比对代看守:若期间同 key 又来了新消息(含下一条 error 顶掉),本 toast 已不再是最新 const guarded = (fn) => () => { if (!armed) return; // 武装期内忽略(弹出的同一手势/连续击键不关) if (el.__xdexImmediateToastSeq !== seq) return; fn(); }; const onPointer = (e) => { // ★ 左键点击、中键按下(button===1,多为自动滚动/关闭标签页习惯动作)、右键按下(button===2,唤出菜单) // 都算「对本页操作」;其余键值(如浏览器前进/后退侧键)不计。 if (e.button !== 0 && e.button !== 1 && e.button !== 2) return; if (interacted) return; interacted = true; startFadeGrace(); const buttonName = e.button === 0 ? '左键' : e.button === 1 ? '中键' : '右键'; console.log(`[xdex error toast] 检测到鼠标${buttonName}操作 → ${ERROR_TOAST_FADE_AFTER_INTERACT_MS / 1000} 秒后渐隐 (target=${e.target && e.target.tagName || '?'}, isTrusted=${e.isTrusted})`); }; const onWheel = (e) => { // ★ 滚轮滚动也算「对本页操作」:阅读长串时最常用的就是滚动,不应要求必须左键点击。 // 只在 NMBXD 页面内容上滚动才算(滚轮事件默认绑定在 document,不覆盖 DevTools 等外部区域)。 if (interacted) return; interacted = true; startFadeGrace(); console.log(`[xdex error toast] 检测到滚轮滚动 → ${ERROR_TOAST_FADE_AFTER_INTERACT_MS / 1000} 秒后渐隐 (deltaY=${e.deltaY}, isTrusted=${e.isTrusted})`); }; const onKey = (e) => { if (interacted) return; interacted = true; startFadeGrace(); console.log(`[xdex error toast] 检测到键盘按键 → ${ERROR_TOAST_FADE_AFTER_INTERACT_MS / 1000} 秒后渐隐 (key=${e.key || '?'}, isTrusted=${e.isTrusted})`); }; pointerH = (e) => { if (!armed) return; if (el.__xdexImmediateToastSeq !== seq) return; onPointer(e); }; wheelH = (e) => { if (!armed) return; if (el.__xdexImmediateToastSeq !== seq) return; onWheel(e); }; keyH = (e) => { if (!armed) return; if (el.__xdexImmediateToastSeq !== seq) return; onKey(e); }; // ★ 提前关闭只认「明确指向本页的操作」:pointerdown / keydown / wheel。 // 不监听 focusin——发送失败后脚本恢复输入框焦点、输入法切换、Alt+Tab 回窗口等 // 都会产生 focusin,会在用户还没看到提示时就把它关掉(实测「红框很快消失」的主因)。 document.addEventListener('pointerdown', pointerH, true); document.addEventListener('keydown', keyH, true); document.addEventListener('wheel', wheelH, true); armTimer = window.setTimeout(() => { armed = true; }, ERROR_TOAST_ARM_DELAY_MS); // ★ 无操作 30 秒驻留上限(从挂载起算;交互后由 5s 渐隐宽限接管,本计时器被 startFadeGrace 清除) holdTimer = window.setTimeout(() => { dismiss(`无操作到达 ${ERROR_TOAST_MAX_HOLD_MS / 1000} 秒驻留上限,自动消失`); cleanup(); }, ERROR_TOAST_MAX_HOLD_MS); // 清理钩子挂到节点:showImmediateToast 复用它时能立刻停止旧监听/计时器(顶掉语义) el.__xdexToastDismissCleanup = cleanup; } const Utils = { // 逗号(中英)分隔,支持转义 \, \, \\ strToList(s) { if (!s) return []; const list = [], esc = ',,\\'; let cur = ''; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch === '\\' && i + 1 < s.length && esc.includes(s[i+1])) { cur += s[++i]; } else if (ch === ',' || ch === ',') { const t = cur.trim(); if (t) list.push(t); cur = ''; } else cur += ch; } const t = cur.trim(); if (t) list.push(t); return [...new Set(list)]; }, cookieLegal: s => /^[A-Za-z0-9]{3,8}$/.test(s), cookieMatch: (cid,p) => cid.toLowerCase().includes(p.toLowerCase()), firstHit(txt,list) { return list.find(k=>txt.toLowerCase().includes(k.toLowerCase()))||null; }, collapse($elem, hint) { if (!$elem.length || $elem.data('xdex-collapsed')) return; // 整表单不要在浮窗内被 early pass 再折成『回复/发串』按钮; // 但 .collapse-wrapper(标题/名称/E-mail 可选项)即使已在 .qp-body 内也必须允许折叠 if ($elem.hasClass('qp-reply-form')) return; if ($elem.closest('.qp-body').length && !$elem.hasClass('collapse-wrapper')) return; const $icons = $elem.find('.h-threads-item-reply-icon'); let nums = ''; if ($icons.length) { const f = $icons.first().text(); const l = $icons.last().text(); nums = $icons.length>1 ? `${f}-${l} ` : `${f} `; } const cap = `${nums}${hint}`; const $ph = $(` <div class="xdex-placeholder xdex-generic-toggle" style=" padding:6px 10px;background:#fafafa;color:#888; border:1px dashed #bbb;margin-bottom:3px;cursor:pointer;"> ${cap}(点击展开) </div> `); $elem.before($ph).hide().data('xdex-collapsed',true); $elem.addClass('xdex-generic-collapsed'); // ★ 标记为公用折叠,以免触发板块页长串折叠/收起 $ph.on('click',()=>{ if($elem.is(':visible')){ $elem.hide(); $ph.html(`${cap}(点击展开)`); } else { $elem.show(); $ph.text('点击折叠'); } }); return $ph; }, // ===== 引用串优化缓存相关 ===== quoteCache: {}, getQuoteFromCache(id) { return this.quoteCache[id] || GM_getValue('quote_' + id, null); }, saveQuoteToCache(id, html) { this.quoteCache[id] = html; GM_setValue('quote_' + id, html); } }; // XDex 门面:设置面板经 spUi/spData/spUpdate 调用,便于实现迁出 tag 0 const XDex = { ui: Object.create(null), data: Object.create(null), update: Object.create(null) }; function xdexCall(bucket, name, args) { const table = XDex[bucket]; const fn = table && table[name]; if (typeof fn !== 'function') { throw new Error('[XDex.' + bucket + '] missing ' + name); } return fn.apply(null, args); } function spUi(name) { return xdexCall('ui', name, Array.prototype.slice.call(arguments, 1)); } // 设置面板按钮 SVG 图标:分组添加(圆圈+加号)/ 保存(软盘) const XDEX_ICON_ADD_GROUP = '<svg class="xdex-icon-add-group" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/></svg>'; const XDEX_ICON_SAVE = '<svg class="xdex-icon-save" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>'; const XDEX_ICON_EYE = '<svg class="xdex-icon-eye" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>'; // 密码可见性:睁眼(密码隐藏态)/ 闭眼带斜线(密码显示态) const XDEX_ICON_EYE_OFF = '<svg class="xdex-icon-eye-off" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12z"/><circle cx="12" cy="12" r="3"/><line x1="4" y1="20" x2="20" y2="4"/></svg>'; // 导入/导出:文件夹带箭头(export=向上、import=向下);重置=红色圆形箭头 const XDEX_ICON_EXPORT_FILE = '<svg class="xdex-icon-export" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 16v-5M9.5 13.5L12 11l2.5 2.5"/></svg>'; const XDEX_ICON_IMPORT_FILE = '<svg class="xdex-icon-import" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 11v5M9.5 13.5L12 16l2.5-2.5"/></svg>'; const XDEX_ICON_RESET = '<svg class="xdex-icon-reset" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#EE0000" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 18A8.5 8.5 0 1 1 18.5 6.5"/><path d="M18.5 6.5l-1.3 3.6"/><path d="M19.2 10.6L18.5 6.5l-3.2 2.7"/></svg>'; // WebDAV 连接检查(插头)/ 手动同步(双向循环箭头) const XDEX_ICON_PLUG = '<svg class="xdex-icon-plug" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7V3M15 7V3"/><path d="M7 7h10v4a5 5 0 0 1-5 5 5 5 0 0 1-5-5z"/><path d="M12 16v5"/></svg>'; const XDEX_ICON_SYNC = '<svg class="xdex-icon-sync" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 0 0-14.9-3"/><path d="M5.1 4v4h4"/><path d="M4 13a8 8 0 0 0 14.9 3"/><path d="M18.9 20v-4h-4"/></svg>'; const XDEX_ICON_GEAR = '<svg class="xdex-icon-gear" viewBox="0 0 24 24" style="display:block;width:16px;height:16px;" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>'; const XDEX_ICON_CHEVRON_DOWN = '<svg class="xdex-icon-chevron" viewBox="0 0 24 24" style="display:block;width:16px;height:16px;" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>'; const XDEX_ICON_CHEVRON_LEFT = '<svg class="xdex-icon-chevron" viewBox="0 0 24 24" style="display:block;width:16px;height:16px;" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>'; // 展开/收起按钮视觉:当前展开→向下箭头;当前收起→向左箭头;图标用 currentColor 与常规图标同色 function setPostExpandToggleVisual(btn, expanded) { if (!btn) return; btn.innerHTML = expanded ? XDEX_ICON_CHEVRON_DOWN : XDEX_ICON_CHEVRON_LEFT; btn.title = expanded ? '点击收起' : '点击展开'; } // 导出串:折角文档 + 两条文字线(与页面内「导」按钮同一图形) const XDEX_EXPORT_ICON = '<svg class="xdex-icon-export" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.5 2.5H6.5A2 2 0 0 0 4.5 4.5v15a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7.5z"/><path d="M14.5 2.5v5h5"/><path d="M8.5 12h7M8.5 16.5h4.5"/></svg>'; const XDEX_ICON_IMAGE = '<svg class="xdex-icon-image" viewBox="0 0 24 24" style="display:block;width:16px;height:16px;" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4"/><circle cx="9" cy="9" r="2"/><path d="M21 16l-4.6-4.6a2 2 0 0 0-2.8 0L5 20"/></svg>'; function spData(name) { return xdexCall('data', name, Array.prototype.slice.call(arguments, 1)); } function spUpdate(name) { return xdexCall('update', name, Array.prototype.slice.call(arguments, 1)); } /* -------------------------------------------------- * tag 1. 设置面板 * -------------------------------------------------- */ // 双刷新支持:如果上一次保存设置时要求执行第二次重载(localStorage 标记), // 则在页面加载时触发第二次重载并清理标记。 try { const _flag = localStorage.getItem('myScriptSettings_doSecondReload'); if (_flag === '1') { localStorage.removeItem('myScriptSettings_doSecondReload'); console.log('[Settings] detected second-reload flag, performing second reload'); // 延迟短暂时间以让页面先完成初始化任务,再执行第二次重载 setTimeout(() => { try { location.reload(); } catch (e) { console.warn(e); } }, 200); } } catch (e) { /* ignore */ } const SETTINGS_BUTTON_ICON_32 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IB2cksfwAAAAlwSFlzAAAuIwAALiMBeKU/dgAACkBJREFUeJy1l3twU3UWx+soPphZd3Udl3XYdZxdfMz6nF1n1BnHXR/soi5EQBaxFkQB0UHXVUFAiyWBIhZZoLUItAVKIfRBaXuT9JFnkyZNbto0zyb35p3cVx6l0IKKytlzb0stD/lHNzOfSXPzy/l+f+f8HqcFBf/HV6xxtsTP/uJDO25gqaoZHH3obj5UeXuW2nXTxWPCjS8U0A2y+5HHkCk/izAX2D6FjTbOZNP2vWwm6mIzcQ8rROws5+9iEx3v8vSeu86PRdFpiAZJIc/+dPHgrn9yKQvP8jSwOQHYfB4ZGn/Pjz3jvEk+Wv+3SQaOIcPi3+KzBlp2Qz0t+8MVhejVsjuQe8NrZDPEz0HHlgIu1vZXJt33LRUjwTRoBiJgho5eNWidnWAIWYGkSQin/JDmKckEF29+vL//86vCDbJrUHz6UVr2NFKOMOPceCUDzyPTkVnh1bJrGPrwrSzraWW4AGj79dA5oAU7ZQNf2AMe2g2WsAM6fN3QbtOBPURCSjQh0EmXv271QcOWWxTHSwpQsASBScz9UQMoOmvcyDO7mzfcru0/siaZcuSotAeMQTOE4k5IcmIZeGCyHMSFJFBsFHQ+Gxx394A/QgKbTUE0pk+ZnZW7jpm3/VkURLhJBjZfKQMPBNfOfU69c0WRsnuHPBBstgX8WognbMBmksCKM8ymse45NJGdWANpNONLeEFj14EvYgZOCDN8vG2hiSybv8Pwwfy6wQVrUbgCeQrXwbUXiCbnvlFAy1ZMRx4kl6+6upSQ34MsCQ0qC2PUcY6idMAke1GYRQMR4DIJFM+MfRbf81mJdCYO9mAvtJMaSLO+b+iI/l0tWXNzqWrjy2Wd62dVOV/7xeVnLVsxFalEKN1H7z+jNG5Zbe3bWcgmDPXpFAkMNwhMwgQcF4KMQAOPKZYyIBqQSEslkUwIEXC7tVgC+whFt7lae2sVOJmbkFnIfZc1EJGtuBrFixHnFweLX96vL33A6Kx5KRhsDLJJI8VmUAAXYZbzw8lsAgRJLCfVWuAHgctxWJ4oZiY6lo0sgxmzDHMRZbnXW9kqJ6pnovhU5PUfrb34wgHTkQX4g98jCxiq7t10rItlhDgG5WAYBc4MZSGfTUoiooEhgcLnYchgFnixNLwfM+aFVKrvHB9tjgt01R6Fqvp5jPcXjP0wMkddbJ+mKbZfP57+milhWc005Eb8sriUKL1FFEceTAZrl7vsdQOBkBsYPICGUfjMiSEYzfPAp51YkgBkeB+M5FL4jIMhNJhifTAQ7gFXb+3XubT5TD6pt2xrr5qB8WQKYu+vUGNji7x7BZp4CJlaEJZV34rMtr65+z78skSuqpmGg5/e1Vl1m0p95KMm5dZ37KR+mM2kIIclOHMiJ5k4iWInhBBkGRKGBT+M5tKQY9wQTLpg25H94CPrY/mUPjKUtuWTEdXCzarqRzHujO312//esLXjVRSfidxTEJtTXRCVVV+ztUUxGw2Iqb8PuVdeTsxAHvfbyq6LBpvXsEzfiJBywEg2jgbykomxbHBweigjGRLSJLj6tWCxagyZmOrRE4ytcZhzscOsO0eHTV0HTIf/gaLvHFcYnfi+Fflkcv2Ltqg2XYUZeAi5E8XnINItx8VbfsdFG1SxQNPZk7gLzmdhMkO4U9Jxy7lUwjacoNVP5eKqhblE+5doYuBUJgI5Pvy1mjxegqLzDu1q+rxV3i2W4JcFe2ThO5E5pa2bFotiOPsn0MBvULxIXtEumeODO6bS3oNrbKaqUcqjgiRthVM59ocsYPozWPt4nDw3MNA+FPFWv3OC6THlk6b4Sd7/1SjunJFc4mxHv+aAGG/zvoNvlbYpbpaCo/ivkbvwQZH4WaGqeVqu3n+TvFz1ihDaOSXkrv2T3a4qN/SYvFpT53dWaxt4XW3fn8RZncb0iyURDQjpAQj4dYDjzvX0diUSlLYaDbxwkvM0jeIiPT0kfO8MaHGZ259TbDI2bP6y7rcXb8FXxjPwpFxdc0tpZetSh61FgwG/7jTbYDJGqwGYuE8yIGbgdF4ALtkPJKmeGKOzmIftdmJt1H/44YjfwLudNthdsfW78m2d59BEUl7seET+kfVSA+sJ5RPLCPOTH9bqN5iMxFcXi2stFrD0GrHe/vES5KVtmGdD4MabcvJYk1XP2W0tH++rVLiL166FooVLobq86is0MIIUKood111g4E2i9drFhGP1a0TP/Dfq7C+o25vtBrPhgqB9fSZIx3xwStoNY/UfzTFYCgbCIdcFYzVaE+iPHeE+2yRP/LtwJby1aDlsLnlvtLSsw4kGqpE7C2QEvQ55ajmhWfEvYkC5jOieWUj0leCz4vJWNemwNOUngnabgXLp4BQXHUs9noojUn0zkMWb0GUlfhDv1IP20Jeeg/sOVJeUfFA2f34RzJ7zMpRsePvb7XvrD6N4G/KsaOCPyP0vEr5H1xDV++YRg4Xr1E0r20jlfwJR6ygdsILRbJSCHiNUYNMehXzSIy4qPANY6RzIxgehsXYnaLraQKMzgq6rHfrNGvCHuvwd1raWaLB9JeXRfeN3mSERNYwe1SjLlAc6PjzwRceDooGZyIvI/FVE08dvEKql27o79zvoXoYXYsCxceh36CQDh5ua4NDuUohh83Eaj+MRvH5DePWWl22Eisod0K5WAWnqhDgdwAsrAxzelAmeOhuOdtO4E86JWRNSHtD3mM64zEor3V3xGBoI34bi9yB3v05oH3lTpa7U+nTtUYb6hs/EpEDRwR5c1Raoa2gA+fr3oM+kQXEGaGzRSj/5EJYtWwZtjbXg7NZAKkGDgA2KCI8XGB31g9dvlzIlGsgyfmky/aajPWHDzkdEA4vE2Y9lITyH6N1TFE66RkVhnseZ4LUr5DNAOdqghWiCD95/Gw5WyGHHp8Xw0qLFsHjJUthf+SnEcQGeFxaJpyLQQ9qgy2IFr9cp3aKigWGxt8TJ9OuUtbSxXMxA5CrkehFxN4Rp9TKGj56VZsC6JwKyuO/tpgY4slcBhyrWwaLCJSCbVwjln60H0nBs3OjYWA6z4xywQ5fZKs3W7ycn7o8RPMD0uKZchiOVdrdy3SU9QTRqUvM4e0FsNsTmY9KsaHcXfLZpHaxauRw2Fq+CPeUfQ8jbjf0fc8G4cNQuHkQTO2IwQE4c2+KOMfd0gd6lptooHX+BeMy1bVo8PfCdFAiv2slBpSykKagsV8CCRUugeMNaoEI9l4wZq30aNIMG0JFaMGG7HvDZxxctdk0sNq4BHSjpXuiLmk9NiBvU6wo8/rrdPB4oggiu8EuCY2ZsxmYo3VQMQcpzWfHzDKRc0BRxwtGwHeopMzRQFhR1QH3EBV1xD0R43GHcoHnCgNu77w46Zh6RAmCdJBOXCZyO9EPIY7qiuIA3pbiA49jEUnho+dkI+LkYBLk4RPH/CC7LS2sGs/HfCQOk79ASBltuKQAesxfX/3wG0qFuSIWubIDnsXzYqIo9otjATl6gE2MyyTMs4371f1z0RWiHZ4pgAAAAAElFTkSuQmCC'; const SETTINGS_BUTTON_ICON_64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAuIwAALiMBeKU/dgAAHVVJREFUeJztewl0G/W9LkmA5raldKX0Ao+errzS0ntvOa/tLfRyeb20r9DYgUIuSUggkA0SAiSQFEISIie2yeJ4i7d4je14X0eWN1m7ZHmXJUuWNNo1iySvCSkJTfJ7v/9IdqR4ycI9r+edc+ec72hGM575f99vHyW33fbf239v/99urpoVS/7ea7jpzd+fsYQZTl/mM6bdETBm3DIBuib+cXt1fC9dHb/GURN/+3/lGv9LN0tP8hLWkvIgZy96kXM1Slh3i4P1Sj/hvLJPOU/nGOduMXJuSsK7GpJZW8lz/Gjmv4Rs6Xcsdk8k/UMk34QAxEVEPX733f9HlG5scxuOLWPNKfdy1pOPs562DpY1TLMhBtjxMcT4VYyFgA1xwAacwHIj51mfjubtp0Qhe/pPnOaMOfdFol9AwhmI6YgABH/D7x/9O9CcuwVGU28PWFN/zNvyPuacjS2sS9zNcqYL7Fgwlvi8QHGISExPgHPVVwdsGT+/9v7o+l9CwlURy88IoCPf/z34xmwBa9odnL04j3G1nmdYCzCcDdigHxjeAV7cd+J3TsYCLnYUvH48z9N43hf2gmvFCPHAemTneFvO20Fb+j9EPwfJfgEt/iQSVyKGEE/MnKuyx99RaY+/Dz9XIRJxf+3nJkbvjruD3h3/CP1e/I/wc8H45K3pTzM+/UWGNQOB362EXloGrbQGGu1aaDDJQaJrAaq/A7qUzdBpkIHCoYYhpxxcHh0wATfCAYx/EMWzojAB9Ia+i7w9f9eYPf0L1z4Pid+H+CmKsbzWEb8Myf4BkYtQI84jADFS5Yq79WqBpB+yvxe/E/EWYjcKsJXePbf8BO3pX2GdDSVk4X4k4GOGQWbvgiZ9O5zpU0FZnwaqVDJoaJNAQ3srNLZKoFrVBRUGFVSNqkA8imLZZOBwydEz7CiABfOCC0jocF75CG/P/VXAnr5svjVW2VcuRaJpiIEI8SsR8gQXquj4B2+JPBL+AWIv4p8EMfY8QwR5AkXIdO1ZOSsCYz7xFdZRs4Xl7eeJ9fy8E7T97VCllkGtvBMqjSqoo9UgcfRAl00HapMSlIMykJhQFHs3nDFooEYphdquDqhTdIDe3AUOEkLsSDg5Ek/wq31uW+mhkq6kBxOpg0uj11lLP78kYnVYAAerHStvvlQi8ScQK5DwrPvR78Utxe9Ujt3xs3HJ2Ip+w3o6u9igFxi0PO0fgRZtG1R0K6Gltx26R8VgdbbhOQOwPOYFzAksWtiNYg27VCB3aqHFqIQ6VUfYQzQYIhg2Ho7GcOjFfMAi/ODxyC509aSeONV5+NEkSvTl6LWilTcuIkAL4ua9AIn+b8RTjihrR75vQ1HuIvuJlOj7o9ZaOcPbLjG+Hly0DbQODVQY1aC1SsDhHQA/fs8GPfNn/jEsgSich7PDkA2FGJJB6YAOygc1ILepUCwMB9aE14YE8VwO8WX1QLatuCspD5/91VkB7HFfRpK+BQTwIt6+aQGQ5E/R5Xcivh/13fdRAAlaYBkuYEt62yEbw/SBP0AD7WgFs98IraMSMNFtQnZnOEyIeO76JTAMPzcKNUi8yNgLjZIW0AzVA8saw/kAz3N+/QWGrjnvHC290qD+WJUiSfj3mbUhyYwFBOAq6fi/3LwH7BbcfRXiL4g/IZ5HHGw9selHSH5fslh0smcou86Pbm82d4J5pBNcbgXY3BrBbUmZY3x9kcXfSB8QBol/BYpQq+mCaoUU+gYlmFQHwz1CwPkZ72l5Gxulxx0jqe/r+o9VlMqStiaKRaT8PYOYjpB2IjoRf8FyeG+t/9mb5i9s7t3xS9AD7kXijyF+WZX75s+QfB1i1+DgsSdZd7vMPloNSm0dWEaUSFgvZG4Cv7cnTD7oBk7oAmfcnl9cBDzvxb9TY2UgOaFZ0QYGazMQoUn+4LzSap7OfzixWbQsSSz6H7iWFMSxYsO6B5HsAST9AeJRzAvfqqY/Rwm8dsOHfBkhijzwXtaSvp1xtXmMw+Ug7WvF+JSD36MIk8AGhwl4wrGP4EK+qCaHtL6OhQUIefH8KDZNJmgdaoXyXjW0GyVg96jD9/IqGJwd4iJrWoL4n4jGJPHBTeWWF4gI37hlknT85m/Z4zdvQRxCPOaI33x75EE/RBxC7CDH43TG11hnY5vfXn3F4WgAj38IS5Ue/E6JkLiEzE3IkIaGlDJCKnoO4EfDLo3JjQs653oFJkeOxzLIGkA5IoWmdjHo+yWCAKQ8Wu2thqOS/IdEzQVCSUxsPEhy0nGSlxDf/DwCrELiEwhAtODxd5KbRLcdEYt24Y1PI+4n12F7+gjjaBjx2htwQYawVZl+FKAVS1dfuJHB+s37upEILRDkZkSZ6fvJNYLF/cAF7OF6H+MJKBA7CE6XGtSddaBUNQgVA4U+73F2fJYvzcsRUQUPJzQXLIkY6Z8RZxDP37IASDo9Qp7Aj/hXvOGj5fIk6eBgyvGANXMXay9NxrovZ9jhC2GLhFtfEuuMR0qmOqHmcyjE2ZATgjPNzLVWJj3BTG5AD+F4Y8RToj0hKNzPieE1YqgRcgrnkVTyrvpSTX/+uZOdpyQowg8jAixFPIOowPzwrVsV4GmEBRFEFObn7v0zuWEyJfr2hCPzbs6a9XtZf2n7gKnGzzprtayL8gm9Ox+JaeINpG4j+XNjPvjr5ASM8Zaw5YQQiAoD9IAgWjh8bjxc6wNW9AY6/F30kITEiaiMRwacm2rg6eKXeFv6keHhk4aM9nwxivDtGQ643v+FyEY8cCsC3IVuvw4/D/Rv2Easn4Y4MHMeH3QfQirtyT/BWXOP8t52tdDhhfjZBEcalwBWA0KeYCpgA35GAIFURAgsbRMBC4Q4YyRJhsUh4cBxpogQnquiobcwfhJm1EXsPkcCtvRs3paRc1qeV4treh/zQThfNQqeQBqlN0mluCVPiCj5W0Q64hGBvLhwOT7oY5G44AXGkvErjq7ScnTFWFgAfzgPCNZywyQufkaA8+M8hHjrlVkrj0XcHq8ZYwfg/EQQr7cCj6RnQ4IIhSHFovdwbA8KgiUw5BGe4/dqPmNs5V7OktrFj6aWGYazHsN11eD6no5a+9ORnPXDzyNAEuKpRIloScT6jyH5ZLLPWU4s451VH7OOxgkGuzfG1xu2FCl9aKXpMe+sAARngw6hKxSmO9wXiCICXkWUUBxMcoNIeADP+WJyARekgWN0gB4HvEeCpbAVWNspJmBNP8qZU7+Ja7sLUYkifCOy9i9FKsLmln36f0T8DnH3DZPHVvfr+MflMyUFif8AH3AQ8VtybOs/fqdpoGyXm26nNYpysOGUN2N9MsBMobWiBSAI+buBdXcJZZFDiwsJj3jAOBtzXViIIWDcUox5NVpcC7RLBgp5CRgHqoF1tVwc98onzgYt/KSvPZG3pn0xYqDtuM5nEcsjIpD+oFB8QBuH5Fsk+/TbJPt65p8K7fGFP6HjC3fS8QXfi/zxOsTWw1gCIzdfh8hGCK+eGHP6i+6RQoXbUibKydx7SSqtizRAHiFZjSPBawX4BImO+3sg4O4EnrS2JM45A4QYPYZBKEqAAIyTGYIMQgEvDLu7ocvQCvWNJ8E2VMDz7s4LY96OsSkMiwmfkuftBc9E1vgI4jjinyIc7iAhXHW05SSSX4FYh0LsFu/rju0OHXH5S5H4ayjA4/i5Af9oOSKLKDhzjYgqTMAbbxaJ82/DavAdvaqgtaP99MGB7vwHc9J3e/JOpYcTIXkFho1KAHuCawX46+S4IMIUZv4p7PSCDLbKWDY5Zy1MMlo8PyZcN425gXNJhVdqDJbONrscDpWdgoyswxB0y6Ymmb4rk0z3lSnecGU6YLk85m5q9Y+cuAstT8IgH7Fq1pObElbXJnWUI/lvovXvQQHScf+3MQJY/k8+NkCFcegFm/HzKSS+gmTRq+QL7sHYyk4QFwrWF2VQdyJKEMKUWFbw/oaivA99dpsmEgbo4h4ljPvUQoKbK8SMR3AYFnr4dGpKsLogECbIMa8avWIAQ2kAzKY22HsqC/YeSoTW5hzdpKd+17gj948B+nTmmKfzHBFzGqtMyNVo5a3ZvzjUUriCeMHM2sX7dU+VpzYoqo9I/g0F+DHiARQhGfFbFGL5rAiuuIJljviCB8yr8kkJ+RDxUZQA/0Hia/Y4g3oEUYgQbjCiO/qAQZOy32mTTJIcQOaAALp4yNMJ50LuBQUQSiS68V8nJ2ePz2HjNI0JL8AO41yhhr7uekhIPg4yuXjCPNy0GbP+HSF300sTfl3fdGD0s7NB22UiwLhPByFnbVVXb+Hjh8UFJWisO1s+7F6CRPfXH+6aaP5IrcD904ijiFJEDuJHc3IB1s3lQieFOWCWsLgQk1/hI1EC5CJi3raG7Bl34ZRWwrrbLrEe2YVwk2PA7E8vKsCM28ckS2YIE58OfPZ6/OwFJshc9rhUH9PGkvs5W8kjU5zBMsX2uMc94uMTPsUkIT/NW1E812eTfpmiQn1aTfIAWvhbSLITAYhPEcOICiIK4jnEvXMFwO4JUYx4khwfpAq+jAKUiepPCQ1FQqZ4GZKfRvzjtX/LmFMf5NziIf9IWoVnOPUyg3NCCDu3c9eUxEU9AgcgH42jr7MNfB7tFZ+r65LPPzLsMOQ8RJ4x7m5JHvdKx8ZctesDdMUH417F36axcZrxItJzDIw0T6MAryLBr6MIq06W5IiSGw7XkKQ4h/DMdireMiPAzxPFoqOIn0es/wu8WfrMdQmZ1E+QvF2U2TrvnM3bc1/3mjLbZeLDkyxdi5ax3DD5v06MYZXAidKPLS8mU4e1dQwHqytO85k3Ohr2LAmMpn0haMt6KUifSQzQleUYPpeneTMm0F4gn4Q8EcLt6JwgDVuUUf+AKEPcMy/53HjHA3nx9F7EF/Gix0kOSKISvisIQBXEowiimWuR/ApE2UJCBm0Z3+ZGM94V14qmhrRZlwOYDElW/2TcDxM41Ezj6DsfeZIsp/F8wNcjlD6PSwfm4UYwDYvBOlSYxI9m/Hrc0/LxNG+ynAvSn05ikiTihnPIOO6PYgiEPW3cr/HjuktEzfnh1rgq5YnEuiPFSWLRD+ZdNBL/aW48LUUh7hY6P0q0EwW4JyLAa5hQZt+nIfmNogzx/mvvwVnSlmKC+s3IYKlF2916RaHpgOqyw6CQJABVmwDi2kSg0a0DrHFe8hPYPJ3DChAkXSVjAnq0Ewx9Z6BdpQO5VgYOY8E5jPlPMMYvBh3VZt5aIEP3nzw/MTabTGcqzjTXf+5wS8EZFOBrwpoPaJsPHRNbEpsSfraQ4W6ren5qxl2eEYYISvS1iABvoAfsihJgBwogvBSh9cfJz2HfGDWcfn14oLoNiV8gC76KbpBpFNDT0wx6PQW8W3ZxGhsk4qpksaRCEMtNY9zPJMMQHjusEjAO1kOHSjt7ry7hPk1u61DRr93D4R9IUIgnJv1aHWmiJvxXhy/ibZQury2rrvzHCfv0O0WYBBNESufh9Nrr/4CKxP+E2I7V4KsRAV5HD3g3SoDtiHf40bQvuk25rxj6qxsU2q5PO9VqiCV/FeRcTw8FQwPiy36HCs6iCFM4+2PWRiECMd4wht3hiKEe+nvr5tyHCNKtFw+jCE+xlrTlvDV1+Zi7YdW5ED1NKkH0fUaHSvw1ZYXihP36ABEAMSXa3/2HGxGATFE7EF+PCPCqKDYEXsNE+BE9nP+WvoeyIvkLCxGPhlrXAUqdDKwWrdDsfCK8KxiPCoOQgAl05eHBBkJ03vtI1apLfb0NSquh6DmynpCj6L4Jbwc15lXOltWQzwjpKR9cSjmSfPFYovpKRIDLiHduRIAnEbsR90UE+CMKkBglwFNZxdW+ga7yv0W76PXQpZYL4dA3oJtNVtEg34WFYcBiqAAVCra4oO2f2YeLXkRPvNvSn/OolEqCjpYzkHJ0P6zf8Co8vWI1vP3mNjh5goKIAIieftFe7dKF2YcF+DV5AYLT4PcjAvxLAlWYHk/Rd8dT9oc2nulbvydf6unuPD0lVSlviDwJAY1OAgqtAoYNSmyO5naHZ7FShNvhCfDZKEDvuu59B/oaBlGE1cM9uc9v27YJ1qx5GVbEr4U/Ivk1f34Fdm97HbLTSv92VQD9ecT91xPgYUQq4hfk+IC49Cu7qMZSJJ+PSF7ZbN+57bQuWN1ebVCrWm9IgO5uCpOaGMdbI0ywpjntMRmQsK2dPZ7ESjAyLL2+sCrVFY283t9cVyRPOPgWvLLqVXh99SbYsXYL7Fm/Dfa9th0aio6YkbR1RoSEfT0HrifAvZH3AL+Pa6ZvR8vveZEy+N4Utz0WFQa5ZWVlWQPqM4xMLVt0kTKNHHyObsG1Z1z92hAY8yhjjqdwmLKYFNcVgKqnoDUt9UplyenPqDMi58ENO2DfK29C0sZ34I01m2H9CxsgO/29T5JOVr+TkNQ54wXjon3dVztCtOrDSHL2l97E5oO3I/mSj6ij7+C5XXju0FuUeDuGwo5nxfY74yjHV/eckv5bck5TjUV2kupWNV1cbJFaFQVjzt7ZeZ+Uv+gJkbj+tR4R8g2DYaBtwXu2KjTQ3NwKHTkpPllZtjg5u7HEpDlStOq59bDzpdfhnZe2CjngT3FrYO8Hb8CpjsTKw6XZ5Ui+P5ILfhctwElEHhLdgJ+/wc9/3US1lq+n1AN4vBWPv7aRUt2/iVIaV1L2Ivwum4TD5upea7vytNSoLBhfVIDOQhh3DsRk/OhxmAwxscnQA3ZlOXTrmue9n1iqgPr8YpDmp07oatLWHc2t340e+UrAlrbjhRfXQRzmgD+vXAd/+NOLEP/ntZCVtvtC6qlK66ETTYeR/GZEEAVIwVBYGhGA/iUSWo84gPsnEMdXU715b1I1g89Slnvw+Gd4LhFF0KMImbj/u/+kTM/kaST9MpN4YlRbclGh6Zp3saRKNJYnw7hrMEIu1tLngq6YcCD7nKEVZA0ZoNR2zrlfm1ID9UXFoKjOAmNXujqzsObJyGT66Bid/pOSU/vO79q5FTZveQ32f7gDyoqSoEed7U4qPqVIEKleRuLYFPUMIhoT9vXeHRHAMWdCSqIOLj1MJSRtoGQiJJyL+A+RuCAOUdCqOfm41lRh6nWqhBegPGcDQ0cZdKnmxqxaK4GEvTuAt2kEtz8bcggJb8bS01GvzaaYUdA3Z8HhD9+ChoaiWKu3d0FTZQV0nM4Dr7ETnB4V9DqkIO+rmmyQVlOW4RyhZE9zg5Pk/eInQoPFhxsiW8ulWsXx4+aB00+ODJT+u8eY/s/6ztIvHdyrmw2Bo2jl38c308LbnnixYwl+9721VPfxVyi5chVl+B6KtPQAVXLvsdbCd6n+crvRIQcOM3UgxEJgfAycvfXQJ62eI4C8qwze2LQJRnWV2KqGfyghb4eFfl2YEiN9PDsC3Y0n4c03NsPW1zeCTN05G+vlZVXQWZoLFp0Y/K5h4XkEfNADVq8eBuzUefVobafPkrlimhu+eG2YeR1qLL/tU0qtdEyhlYYsshw7Zz7xRFQOoHciMpB0MeIY7qchcJ8+8gGVVXmISnwQBfjqs5R1//tSlUY10niFC7iAx+QVCPlwMePA2PVgls/1AnFTLrz88gbQi/Ngwt0vkA+PrOgB44wwB4x7dGDvKoIP390Occ+thSMf78XQ0YC4Uw51BcWgqckB+6AMON4DgbGQ8LwZ8CE/eDkLmJztl5SWOgPOFFeuFcDv7IlZk0mSC4wpZXWMy8c1OEkofBHxXcR34ihaGCP3Uye2bKTaGldS1tNHlPLtvSPVMjczEn64IIA3vJixADCOHhhUx/bvNZXH4ZmVa6Ay7UPgzao5Lz4YkwyaCw/D2nUvC83LwYPvQiNVDfVl5dBekgXOoXbgx4IxpGOA5xjOBRZsr7XoIWfnGbV5T1/MmiwtGcCMpDwV5QFzcwDZVoodSzEZbttAdXlfpaRbBk3FG0fs4mku6I0IYL8qADlGCzn6arBXv9od1lYeE7Jx8gdvw4i84mr2Jz+IjKqgsfAQbHz1VeGaVWvWw5kzWSCurwZFRQ64DVK0MLcweQwDj9cMRpMaew0lunmnkFfmCtAfk5TtrSeAG019OLoMzpsDEG8hPt5LZZ7AhJgxYqnQuL09kRgkAlhxgd6YBbGeARhQVGLrqwo3Kg3pArnVa9dDc94hYQCaYixgVZVD8v63gZQtcp4gNWUfNBbngbIyG1ifRbDuQuR5FNCJpVXVrcRnaYRnabpbUVj/3PeLvqsCkJHa0ZpyJWBLFypA0Jb2m9sE8hRdiWQDCCPu2xAWxL6VEiv51xf/kNeekD1qb8UH87OL4Nh+jEuL4P6zi5sYB2dPBfTKwqGgUxXD758JE9z33nZwqXNAV58EWzdvnCVO8M47W6CjLhv6GrOAwZK5sNWRfNAPRmOXMFhFu7YO2+1rR+vw26He2WuU2g6gm06cZa3vL/GbTixjhk8ULJoDyDagTLjDYMg5TP7lZ/RCOK8cvcA5Z4GMYwCs8lJQqKQ40DRDVeEHQoIrTN8DR0Vvw5qXXo4hv2nzayA5cwiUNbnA+rGyjIcWsTyL5NVkJJ5TcfqwEs28HYrJNZwBOtTaSFcqRgFSteP2jCW20VNrXd1phvnCP2ajB4/90mwuVcdYenwCOE97pAxes1CMW/+oBgYVVYKVOhsOgqn6GJjrj8Pe3W/AyufXzpJ/EUOjNG0P9DZlInnrbHjNb3mP8PvjfOQJhvpr5u02zwZtOIqH85JO2gROaUqGw5r3U6mV6jUMFZkXJZ997NXbjIbs3Q5azMRmXx54X9eCC+YYGuyqMiHmqOpDkC7aBflH90BbxQHIT90tZH0iwIG/bIe+phScEocWJU+8wunQQ3fv/B0ngXGwat6XrZ+MeUGuCQ9suo4G8Pac2Nlrr86psyk+7TVXtC0qgLk/9X7zSJnWy5hireHXQgAtsliskrJoVhRASXEKPB2/WiD8n5jpEz/aAS1V+0F04E2g+4qBZWyL3mcm6ZnMHaDSty8ogHmoCj6dmpwrAg5hmm7yd1rQK2qg11rJF9v7oMkmhQFbRcKiAhiHsh+3060Mw7uirB8A3ttx3UUHcNFuTT401GTGZPs/xq2GDz/YBkOadGC9fdex/NUKY/dgrTe1gLRbFgmD2LdRpsEFBED09lIg13WBfKgZSuw9UEL3Q6+96ZLDdnrz4h5gyt/r9g9ejilJLGbp4NzkNx9YnxH07WmweeuWWQGefeElqCo7gn3D6A2Sv+oFBrcOKke1IMYqIMVxWdMtAZWuHWNcAQO9dejurDBrkH6ANFpjzAC4sHRLza1Qa1FBkb0f8u2DoMc5xufpcrPOhqcWJD+sPrTcPFrr4QLuWOuzfTe8aAI3uu6RQ7sF4hs3b4HqyhzgAouHz8JexUKLXQWn6CHIow2QTw/CaVsPVNlUUGeTQ4NVBrX4WWVTI9m+2evIZwkeSxzd4CReR/7lmVd92mfJ+/GCAlj6jv/MQrddicn+2PuTf8dzM4tmPCNAnTkCm7H2l5RkgtdnvjXyEZhcMmi0K6Hc3o3EDALBhUDOF9t7oYHWg8KpB7c/snbyL9Q9KpHHlPntecnLWt6/vc9YeNjju8ba6Pp8wHGTVgtgi9wEjeXHwcfcWOgsfC8vVh8VitiNk2Av6B0ykNk7QGLtgGZrJzRau4CySqHV1gUKWoWujrnBZwAv7xBCaDacgu7PGLfsRUt/7vw/lroNxx/vM5Xq5/TiZAAiv93fzKJxgvP0NYJLW/z5yJMRmNFHPofQEDOJeUJ4BulSFx2covMJT3t9topfzUt+tDvpTr2pLMtil5yd88c4zgYC1y9bMQ8LsuA1UeAxNn8+8tjRkflDuCd6IY9j8K3ej2MG5S7D8fnd32LIfEg/Ujns8A/N44I4//PGGyePscZgvLr7ysGlL4HF2tzFRUTX581C7M6S4Mzzd6I3AL+jMbFXJpr/BxKtsWzFsI2a9nH2edwZEyLbe4OuHwDPMAUuTTEOQXn4iYOOQ3VriyahJ+SeqLKJ4clxI7cgphu8Izmz/3vi/wLKEJjy325E2wAAAABJRU5ErkJggg=='; function installSettingPanelEasterEgg(root = document) { const scope = root && root.querySelector ? root : document; const title = scope.querySelector('.xdex-setting-title-easter-egg'); if (!title || title.__xdexSettingEasterEggBound) return; title.__xdexSettingEasterEggBound = true; const CLICK_WINDOW_MS = 3000; const REQUIRED_CLICKS = 3; const EASTER_EGG_TID = '68811442'; let clicks = []; title.addEventListener('click', () => { const now = Date.now(); clicks = clicks.filter(t => now - t <= CLICK_WINDOW_MS); clicks.push(now); if (clicks.length < REQUIRED_CLICKS) return; clicks = []; try { if (typeof enableQuotePreview === 'function') enableQuotePreview(); if (typeof window.__xdexOpenQuoteByTid === 'function') { window.__xdexOpenQuoteByTid(EASTER_EGG_TID, { fromPOImage: true }); } } catch (e) { console.warn('[X岛-EX] setting panel easter egg failed', e); } }); } const SettingPanel = { key: 'myScriptSettings', defaults: { enableCookieSwitch: true, enableCookieConfirm: false, // 发送前确认饼干 disableWatermark: true, // note: `duplicatePagination` 已弃用,使用 `enablePaginationDuplication` enablePaginationDuplication: true, updatePreviewCookie: true, hideEmptyTitleEmail: true, enableExternalImagePreview: true, // 外部图床显示 enableAutoCookieRefresh: true, enableAutoCookieRefreshToast: false, interceptReplyFormUnvcode: true, // 拦截回复中间页--unvcode interceptReplyFormU200B: true, interceptReplyFormAutoCompress: true, enableSeamlessPaging: true, enableAutoSeamlessPaging: true, enableHDImageAndLayoutFix: true, // 启用高清图片链接 enableLinkBlank: true, // 串页新标签打开 enableAutoUrlLinkify: true, enableQuotePreview: true, // 优化引用弹窗 enableUpdateCheck: true, enableImageContextMenu: true, enableImageHideMode: true, // 图片隐藏/无图模式 applyImageHideMode: 'default', // default | blur | noimage | tips imageViewerSeparators: true, // 阅图模式:显示页码分隔线与“第N页”标识 enableDraft: true, enableThreadHistory: true, // 浏览历史:关闭后不再自动记录(已有数据保留,侧边栏入口隐藏) enablePostHistory: true, // 发言历史:关闭后不再在发送消息后自动记录(手动添加不受影响) timeDisplayMode: 'relative', // relative | exact extendQuote: true, // 拓展引用格式 extendQuoteAvailabilityDetection: true, // 检测引用串是否存在 enablePostExpandAll: true, // 默认展开板块页长串 kaomojiSort: 'default', // 颜文字排序:default | freq | recent toggleSidebar: false, // 侧边栏收起功能 enableCustomDarkTheme: 'light', // 自定义深色模式:light=浅色 / dark=深色 dockDisplayMode: 'fixed', // 扩展坞增强:hover=隐藏(悬浮显示)| fixed=固定显示(默认) postAfterAction: 'jump', // 发串后:jump=新标签页打开 / refresh=刷新页面回板块第一页 disableAutoQuote: true, // 关闭引用:阻止URL中?r=参数自动插入引用号 threadCookieWhitelistGroups: [], threadCookieWhitelistDisplayMode: 'fold', // 只看饼干:fold | hide | column poAnnotationSideDisplayMode: 'collapse', // 分栏侧栏:collapse | expand replyModeDefault: '回复', // 板块页默认模式:发串/回复 replyExtraDefault: '临时', // 板块/时间线默认额外模式:临时/连续 markedGroups: [], blockedCookies: [], blockedKeywords: [], favoriteThreads: [], subscriptionFeeds: [], // [{desc, uuid}, ...] // 默认订阅号:'' = 未设置(点击“订阅EX”时每次弹窗选择) // updatedAt 用于 WebDAV / 导入导出按修改时间戳合并(新者胜;从未设置过则任一方有值即覆盖) defaultSubscriptionFeedUuid: '', defaultSubscriptionFeedUpdatedAt: 0, // 分组顺序元数据:{ [分组字段名]: { order: [语义键...], at: 时间戳 } } // 用于把「拖拽排序」作为可传播设置随 WebDAV/导入导出同步(静默合并,不触发冲突弹窗) groupOrderMeta: {}, blockDisplayMode: 'hide' // fold = 折叠 | hide = 隐藏 }, state: {}, // JSONC 解析(支持 // 注释和尾随逗号) parseJSONC(str) { const cleaned = str .replace(/\/\/.*$/gm, '') .replace(/,(\s*[}\]])/g, '$1'); return JSON.parse(cleaned); }, // 导出为 JSONC 字符串 buildJSONC(state) { const lines = [ '// X岛-EX 配置文件', '// 支持 // 注释和尾随逗号', '{', ' "_meta": {', ` "version": "${typeof VERSION !== 'undefined' ? VERSION : 'unknown'}",`, ` "exportedAt": "${new Date().toISOString()}",`, ' "source": "nmbxd-EX"', ' },', ' "settings": {', ]; const entries = Object.entries(state); entries.forEach(([key, val], i) => { const json = JSON.stringify(val, null, 2); if (json.includes('\n')) { const indented = json.split('\n').map((line, li) => li === 0 ? line : ' ' + line).join('\n'); lines.push(` ${JSON.stringify(key)}: ${indented}${i < entries.length - 1 ? ',' : ''}`); } else { lines.push(` ${JSON.stringify(key)}: ${json}${i < entries.length - 1 ? ',' : ''}`); } }); lines.push(' },'); lines.push('}'); return lines.join('\n'); }, // 校验导入的配置 validateImport(incoming) { if (typeof incoming !== 'object' || incoming === null) return { valid: false, error: '配置内容无效' }; const defaults = this.defaults; const validated = {}; let skipped = 0; for (const [key, val] of Object.entries(incoming)) { if (!(key in defaults)) continue; if (key === 'blockedKeywords') { if (typeof val !== 'string' && !Array.isArray(val)) { skipped++; continue; } validated[key] = spData('normalizeBlockedKeywordGroups', val); continue; } if (typeof val !== typeof defaults[key]) { skipped++; continue; } if (Array.isArray(defaults[key]) && !Array.isArray(val)) { skipped++; continue; } validated[key] = val; } return { valid: true, merged: Object.assign({}, defaults, validated), skipped }; }, // 从文本导入(校验 + 暂存) importFromText(text) { let parsed; try { parsed = this.parseJSONC(text); } catch (e) { toast('配置文件格式错误'); return; } const incoming = parsed.settings || parsed; const result = this.validateImport(incoming); if (!result.valid) { toast(result.error); return; } this.__pendingImport = result.merged; const msg = result.skipped > 0 ? `格式正确(${result.skipped} 个字段已跳过),请点击[应用]` : '格式正确,请点击[应用]'; toast(msg); const btn = document.getElementById('btn_xdex_import_export'); if (btn) btn.classList.remove('xdex-inv'); }, // 导出到剪贴板 async exportToClipboard() { try { const jsonc = this.buildJSONC(this.state); await navigator.clipboard.writeText(jsonc); toast('配置已复制到剪贴板'); } catch (e) { toast('复制失败,请重试'); } }, // 导出为文件 exportToFile() { const jsonc = this.buildJSONC(this.state); const blob = new Blob([jsonc], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `x岛-ex-settings-v${typeof VERSION !== 'undefined' ? VERSION : 'unknown'}.jsonc`; a.click(); URL.revokeObjectURL(a.href); toast('配置已导出'); }, // 从剪贴板导入 async importFromClipboard() { try { const text = await navigator.clipboard.readText(); if (!text) { toast('剪贴板为空'); return; } this.importFromText(text); } catch (e) { toast('无法读取剪贴板,请先点击页面后再试'); } }, // 从文件导入 importFromFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,.jsonc,.txt'; input.onchange = () => { if (!input.files || !input.files[0]) return; const reader = new FileReader(); reader.onload = () => this.importFromText(reader.result); reader.readAsText(input.files[0]); }; input.click(); }, syncAuxiliaryControls() { const whitelistModeSelect = document.getElementById('sp_threadCookieWhitelistDisplayMode'); if (whitelistModeSelect) { whitelistModeSelect.value = (this.state && this.state.threadCookieWhitelistDisplayMode) || 'fold'; } const poSideModeSelect = document.getElementById('sp_poAnnotationSideDisplayMode'); if (poSideModeSelect) { poSideModeSelect.value = (this.state && this.state.poAnnotationSideDisplayMode) || 'collapse'; } const draftSelect = document.getElementById('sp_enableDraftMode'); if (draftSelect) { const enabled = !!(this.state && this.state.enableDraft); draftSelect.value = enabled ? 'off' : 'on'; } const expandSelect = document.getElementById('sp_postExpandAllMode'); if (expandSelect) { const enabled = !!(this.state && this.state.enablePostExpandAll); expandSelect.value = enabled ? 'expand' : 'collapse'; } const timeDisplaySelect = document.getElementById('sp_timeDisplayMode'); if (timeDisplaySelect) { timeDisplaySelect.value = (this.state && this.state.timeDisplayMode === 'exact') ? 'exact' : 'relative'; } const postAfterSelect = document.getElementById('sp_postAfterAction'); if (postAfterSelect) { postAfterSelect.value = (this.state && this.state.postAfterAction === 'refresh') ? 'refresh' : 'jump'; } // 快速回复默认模式(跨页/重开面板回填) const replyModeSelect = document.getElementById('sp_replyModeDefault'); if (replyModeSelect) { replyModeSelect.value = (this.state && this.state.replyModeDefault === '发串') ? '发串' : '回复'; } const replyExtraSelect = document.getElementById('sp_replyExtraDefault'); if (replyExtraSelect) { replyExtraSelect.value = (this.state && this.state.replyExtraDefault === '连续') ? '连续' : '临时'; } }, init() { const saved = GM_getValue(this.key, {}); const isFirstInit = Object.keys(saved).length === 0; // 判断是否首次初始化 // console.log('init读取的原始数据:', JSON.stringify(saved)); this.state = Object.assign({}, this.defaults, saved); if (this.state.timeDisplayMode !== 'exact') this.state.timeDisplayMode = 'relative'; // 该功能为固定启用项:避免历史配置把它保存为 false 导致下拉无法生效 this.state.enableImageHideMode = true; // console.log('init合并后的state:', JSON.stringify(this.state)); // 兼容迁移:屏蔽饼干到组结构 this.state.markedGroups = spData('normalizeMarkedGroups', this.state.markedGroups); this.state.blockedCookies = spData('normalizeBlockedGroups', this.state.blockedCookies); this.state.blockedKeywords = spData('normalizeBlockedKeywordGroups', this.state.blockedKeywords); this.state.favoriteThreads = spData('normalizeFavoriteThreads', this.state.favoriteThreads); this.state.threadCookieWhitelistGroups = spData('normalizeThreadCookieWhitelistGroups', this.state.threadCookieWhitelistGroups); // 清理废弃字段 const validKeys = Object.keys(this.defaults); let needCleanup = false; Object.keys(this.state).forEach(key => { if (!validKeys.includes(key)) { delete this.state[key]; needCleanup = true; } }); // console.log('init清理后的state:', JSON.stringify(this.state)); // 只在首次初始化或需要清理废弃字段时才保存 if (isFirstInit || needCleanup) { console.log('首次初始化或需要清理,执行保存'); GM_setValue(this.key, this.state); } this.render(); GM_addValueChangeListener(this.key,(k,ov,nv,remote)=>{ if(remote){ this.state = Object.assign({}, this.defaults, nv); this.state.markedGroups = spData('normalizeMarkedGroups', this.state.markedGroups); this.state.blockedCookies = spData('normalizeBlockedGroups', this.state.blockedCookies); this.state.blockedKeywords = spData('normalizeBlockedKeywordGroups', this.state.blockedKeywords); this.state.favoriteThreads = spData('normalizeFavoriteThreads', this.state.favoriteThreads); this.state.threadCookieWhitelistGroups = spData('normalizeThreadCookieWhitelistGroups', this.state.threadCookieWhitelistGroups); if (this.state.timeDisplayMode !== 'exact') this.state.timeDisplayMode = 'relative'; this.syncInputs(); this.syncAuxiliaryControls(); // 面板渲染后回填 WebDAV 已保存配置(tag29),避免需先点击输入框才显示 try { if (typeof ensureWebdavConfigInPanel === 'function') ensureWebdavConfigInPanel(); } catch (e) {} try { renderFavoriteThreadsMenu(); } catch (e) {} try { refreshFilterDisplay(this.state); } catch (e) {} try { if (typeof window.__xdexApplyTimeDisplayMode === 'function') window.__xdexApplyTimeDisplayMode(document); } catch (e) {} try { if (typeof applyImageViewerSeparatorSetting === 'function') applyImageViewerSeparatorSetting(); } catch (e) {} // 引用号拓展(跨页同步):开启时对当前页重跑一次(幂等) try { if (this.state.extendQuote !== false && typeof extendQuote === 'function') extendQuote(document); } catch (e) {} // 发送前确认(跨页同步):按最新 state 运行时注入/清理串内检查区 try { if (this.state.enableCookieSwitch && this.state.enableCookieConfirm) { const _tid = typeof PageType !== 'undefined' && PageType.getThreadId ? PageType.getThreadId(false) : ''; const _short = _tid ? String(_tid).slice(0, 8) : ''; if (_short) { if (typeof initThreadCookiePref === 'function') initThreadCookiePref(_short); if (typeof injectCookieCheckSwitch === 'function') injectCookieCheckSwitch(_short); } } else if (typeof clearCookieCheckSwitch === 'function') { clearCookieCheckSwitch(); } } catch (e) {} } }); }, render() { if (!$('#xdex-setting-style').length) { $('head').append(` <style id="xdex-setting-style"> :root { --xdex-sp-panel-bg: #FFFFEE; --xdex-sp-fold-bg: #F0E0D6; --xdex-sp-footer-bg: #FFFFEE; --xdex-sp-border: #eee; --xdex-sp-shadow: rgba(0,0,0,0.2); } :root.xdex-darkreader-active { --xdex-sp-panel-bg: #2b2c2d; --xdex-sp-fold-bg: #483327; --xdex-sp-footer-bg: #2b2c2d; --xdex-sp-border: #4b4d50; --xdex-sp-shadow: rgba(0,0,0,0.55); /* shadcn 风格 token:脚本内从未定义,导致 42 处 var() 恒取 fallback。 深色下面板底色已是 #2b2c2d,而 fallback 的 #333/#666 深字压深底不可读; 仅在深色类下补定义,light 模式继续走原 fallback(行为零变化) */ --foreground: #d9d0d0; --muted-foreground: #9a9a9a; } .xdex-inv {opacity:0;pointer-events:none;} #sp_panel { background: var(--xdex-sp-panel-bg) !important; box-shadow: 0 2px 10px var(--xdex-sp-shadow) !important; } .sp_fold, .sp_fold_head, .sp_fold_body { background: var(--xdex-sp-fold-bg) !important; border-color: var(--xdex-sp-border) !important; } #sp_panel_footer { background: var(--xdex-sp-footer-bg) !important; border-top-color: var(--xdex-sp-border) !important; } /* 设置按钮样式:图标入口 */ #sp_btn { position:fixed; top:10px; right:10px; z-index:10000; width:36px; height:36px; padding:2px; border:none; background:rgba(102,204,255,.92); border-radius:50%; display:flex; align-items:center; justify-content:center; overflow:visible; box-shadow:0 1px 4px rgba(0,0,0,.18); transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; cursor:pointer; } #sp_btn:hover { background:#66CCFF; box-shadow:0 2px 8px rgba(0,0,0,.24); transform:translateY(-1px); } #sp_btn img { display:block; width:32px; height:32px; pointer-events:none; user-select:none; } #sp_btn.xdex-has-update::after { content:'!'; position:absolute; right:-6px; top:-6px; width:18px; height:18px; border-radius:50%; background:#e53935; color:#fff; font-size:12px; line-height:18px; text-align:center; font-weight:bold; box-shadow:0 1px 4px rgba(0,0,0,.25); } #sp_panel_footer .sp_panel_links.xdex-update-highlight { box-shadow: inset 0 0 0 2px #ff4d4f, 0 0 0 2px rgba(255,77,79,.18); background: linear-gradient(90deg, rgba(255,77,79,.08), rgba(255,193,7,.08)) !important; transition: box-shadow 0.2s ease, background 0.2s ease, filter 0.2s ease; } #sp_panel_footer .sp_panel_links.xdex-update-source-greasyfork { box-shadow: inset 0 0 0 2px rgba(255,152,0,.68), 0 0 0 2px rgba(255,152,0,.16); } #sp_panel_footer .sp_panel_links.xdex-update-source-scriptcat { box-shadow: inset 0 0 0 2px rgba(18,150,219,.68), 0 0 0 2px rgba(18,150,219,.16); } #sp_panel_footer .sp_panel_links a.xdex-update-link-primary { color:#c62828 !important; font-weight:700; text-decoration:underline; text-underline-offset:2px; } #sp_panel_footer .sp_panel_links a.xdex-update-link-secondary { color:#8a1f1f !important; font-weight:600; } #sp_panel_tab_slot { position:absolute; left:-112px; top:48px; width:112px; display:flex; flex-direction:column; align-items:flex-end; gap:6px; } #sp_panel_tab_slot:empty { display:none; } #sp_panel_tab_slot .sp_panel_tab { --sp-panel-tab-bg:#006666; --sp-panel-tab-icon-size:28px; --sp-panel-tab-label-width:4em; --sp-panel-tab-collapsed-width:42px; --sp-panel-tab-expanded-width:calc(var(--sp-panel-tab-icon-size) + var(--sp-panel-tab-label-width) + 20px); width:var(--sp-panel-tab-collapsed-width); min-height:50px; padding:6px; border:1px solid var(--xdex-sp-border); border-right:none; border-radius:999px 0 0 999px; background:var(--sp-panel-tab-bg); color:#fff; cursor:pointer; display:flex; align-items:center; justify-content:flex-start; gap:8px; overflow:hidden; box-sizing:border-box; transition:width .16s ease, filter .16s ease; } #sp_panel_tab_slot .sp_panel_tab[data-sp-module="settings"] { --sp-panel-tab-bg:#006666; } #sp_panel_tab_slot .sp_panel_tab[data-sp-module="history"] { --sp-panel-tab-bg:#0080FF; } #sp_panel_tab_slot .sp_panel_tab[data-sp-module="posts"] { --sp-panel-tab-bg:#FFFF00; color:#332200; } #sp_panel_tab_slot .sp_panel_tab[data-sp-module="feeds"] { --sp-panel-tab-bg:#39C5BB; color:#440022; } #sp_panel_tab_slot .sp_panel_tab.active, #sp_panel_tab_slot .sp_panel_tab:hover, #sp_panel_tab_slot .sp_panel_tab:focus, #sp_panel_tab_slot .sp_panel_tab:focus-visible, #sp_panel_tab_slot .sp_panel_tab.is-hover { width:var(--sp-panel-tab-expanded-width); font-weight:bold; } #sp_panel_tab_slot .sp_panel_tab_icon { width:var(--sp-panel-tab-icon-size); height:var(--sp-panel-tab-icon-size); line-height:var(--sp-panel-tab-icon-size); border-radius:50%; background:rgba(255,255,255,.18); flex:0 0 var(--sp-panel-tab-icon-size); text-align:center; display:flex; align-items:center; justify-content:center; } #sp_panel_tab_slot .sp_panel_tab_icon svg { width:72%; height:72%; display:block; margin:auto; } #sp_panel_tab_slot .sp_panel_tab_label { width:var(--sp-panel-tab-label-width); flex:0 0 var(--sp-panel-tab-label-width); text-align:center; white-space:nowrap; opacity:0; transition:opacity .16s ease; } #sp_panel_tab_slot .sp_panel_tab.active .sp_panel_tab_label, #sp_panel_tab_slot .sp_panel_tab:hover .sp_panel_tab_label, #sp_panel_tab_slot .sp_panel_tab:focus .sp_panel_tab_label, #sp_panel_tab_slot .sp_panel_tab:focus-visible .sp_panel_tab_label, #sp_panel_tab_slot .sp_panel_tab.is-hover .sp_panel_tab_label { opacity:1; } #sp_panel_views { display:flex; flex-direction:column; flex:1; min-height:0; } .sp_panel_module.active { display:flex; flex-direction:column; flex:1; min-height:0; } #sp_module_history.sp_panel_module.active, #sp_module_posts.sp_panel_module.active, #sp_module_feeds.sp_panel_module.active { display:flex; flex-direction:column; flex:1; min-height:0; } .sp_panel_module:not(.active) { display:none; } .sp_panel_content { padding:18px; overflow-y:auto; flex:1 1 auto; min-height:300px; box-sizing:border-box; } #sp_history_content { display:block; } .xdex-history-toolbar { display:flex; align-items:center; gap:8px; margin:0 0 10px; } .xdex-history-toolbar input { flex:1; min-width:0; padding:6px 8px; border:1px solid var(--xdex-sp-border); border-radius:8px; background:var(--xdex-sp-panel-bg); } .xdex-history-toolbar select { padding:6px 8px; border:1px solid var(--xdex-sp-border); border-radius:8px; background:var(--xdex-sp-panel-bg); } .xdex-history-toolbar .xdex-history-count { white-space:nowrap; } .xdex-history-toolbar .xdex-history-search-help { display:inline-block; margin-left:3px; text-decoration:underline; cursor:help; color:inherit; } #sp_history_results { display:block; min-height:40px; padding-top:8px; color:inherit; } #sp_posts_results { display:block; min-height:40px; padding-top:8px; color:inherit; } .xdex-post-history-type-buttons { display:flex; gap:8px; margin:0 0 10px; } .xdex-post-history-type-buttons button { flex:1; padding:6px 8px; border:1px solid var(--xdex-sp-border); border-radius:8px; background:var(--xdex-sp-panel-bg); cursor:pointer; } .xdex-post-history-type-buttons button.active { background:#F0E0D6; color:#332200; font-weight:bold; } .xdex-recycle-btn { position:relative; display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; padding:0; border:1px solid var(--xdex-sp-border); border-radius:8px; background:var(--xdex-sp-panel-bg); cursor:pointer; color:inherit; } /* 通用图标按钮(分组添加/保存):与回收站按钮同款外观 */ .xdex-icon-btn { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; padding:0; border:1px solid var(--xdex-sp-border, #bfa58f); border-radius:8px; background:var(--xdex-sp-panel-bg, #F0E0D6); cursor:pointer; color:inherit; } :root.xdex-custom-dark .xdex-icon-btn, :root.xdex-darkreader-active .xdex-icon-btn { background:#2b2c2d; border-color:#555; color:#d9d0d0; } .xdex-icon-btn:hover { border-color:#00ffcc; color:#00ffcc; } /* 图标按钮专用隐藏类:占位透明(与 xdex-inv 布局语义一致,避免折叠/展开高度突变) */ .xdex-btn-hidden { opacity:0; pointer-events:none; } .xdex-recycle-btn:hover { border-color:#EE0000; color:#EE0000; } .xdex-recycle-badge { position:absolute; top:-6px; right:-6px; min-width:16px; height:16px; padding:0 4px; border-radius:999px; background:#EE0000; color:#fff; font-size:10px; line-height:16px; text-align:center; pointer-events:none; } .xdex-recycle-bar { display:none; align-items:center; gap:8px; margin:0 0 10px; padding:6px 8px; border:1px solid var(--xdex-sp-border); border-radius:8px; background:var(--xdex-sp-fold-bg); } .xdex-recycle-bar .xdex-recycle-back { padding:4px 10px; border:1px solid var(--xdex-sp-border); border-radius:8px; background:var(--xdex-sp-panel-bg); cursor:pointer; } .xdex-recycle-bar .xdex-recycle-bar-title { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .xdex-recycle-bar .xdex-recycle-empty-btn { padding:4px 10px; border:1px solid var(--xdex-sp-border); border-radius:8px; background:var(--xdex-sp-panel-bg); cursor:pointer; } .xdex-recycle-bar .xdex-recycle-empty-btn:disabled { opacity:.5; cursor:not-allowed; } .xdex-recycle-mode .xdex-history-toolbar, .xdex-recycle-mode .xdex-post-history-type-buttons { display:none; } .xdex-recycle-mode .xdex-posts-add-row { display:none !important; } .xdex-recycle-mode .xdex-recycle-bar { display:flex; } .xdex-recycle-empty { margin:24px 0; padding:20px 12px; border:1px dashed var(--xdex-sp-border); border-radius:8px; text-align:center; color:var(--foreground, #333); opacity:.75; } .xdex-history-tombstone-mark, .xdex-post-history-tombstone-mark { position:absolute; top:-9px; right:34px; width:20px; height:20px; border:1px solid #a98f7a; border-radius:999px; background:#F0E0D6; display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer; z-index:1; color:#5b4636; } .xdex-history-tombstone-mark:hover, .xdex-post-history-tombstone-mark:hover { color:#EE0000; border-color:#EE0000; } .xdex-recycle-sort { padding:4px 6px; border:1px solid var(--xdex-sp-border); border-radius:6px; background:var(--xdex-sp-panel-bg); color:inherit; cursor:pointer; } .xdex-recycle-item { position:relative; border:1px solid #bfa58f; border-radius:8px; padding:10px 12px; margin-bottom:8px; background:rgba(255,255,255,0.18); } .xdex-recycle-item-title { font-weight:bold; margin-bottom:4px; word-break:break-all; } .xdex-recycle-item-meta { font-size:12px; opacity:.75; } .xdex-recycle-item.xdex-recycle-highlight { animation:xdexRecycleHighlight 2s ease; } @keyframes xdexRecycleHighlight { 0%,60% { background:#ffe08a; } 100% { background:rgba(255,255,255,0.18); } } .xdex-recycle-item-meta { display:flex; align-items:center; gap:6px; } .xdex-recycle-item-meta > span { flex:1; min-width:0; } .xdex-recycle-icon-btn, .xdex-recycle-item-btn { background:none; border:none; padding:2px; cursor:pointer; color:#6b5644; display:inline-flex; align-items:center; justify-content:center; border-radius:4px; } .xdex-recycle-icon-btn:hover { color:#EE0000; } .xdex-recycle-item-restore:hover { color:#00ffcc; } .xdex-recycle-item-purge:hover { color:#EE0000; } .xdex-recycle-item-actions { display:flex; gap:6px; margin-top:8px; justify-content:flex-end; } .xdex-recycle-item-btn { padding:3px 10px; border:1px solid var(--xdex-sp-border); border-radius:6px; background:var(--xdex-sp-panel-bg); color:inherit; cursor:pointer; font-size:12px; } .xdex-recycle-item-btn.xdex-recycle-item-restore:hover { border-color:#00ffcc; color:#00ffcc; } .xdex-recycle-item-btn.xdex-recycle-item-purge:hover { border-color:#EE0000; color:#EE0000; } .xdex-history-item { display:block !important; position:relative; margin:10px 0 8px; border:1px solid var(--xdex-sp-border); border-radius:8px; background:var(--xdex-sp-fold-bg); overflow:visible; } .xdex-history-item .h-threads-item-main { display:block !important; position:relative; padding:10px; background:transparent; } .xdex-history-item .h-threads-content { display:block !important; margin:15px 40px 6px; font-size:14px; line-height:20px; white-space:pre-wrap; overflow-wrap:anywhere; } .xdex-history-item .h-threads-info { display:flex; align-items:flex-start; gap:8px; line-height:1.5; margin-bottom:6px; } .xdex-history-info-main { flex:1 1 auto; min-width:0; } .xdex-history-item .h-threads-info-title { color:#cc1105; font-weight:bold; } .xdex-history-item .h-threads-info-email { color:#117743; font-weight:bold; } .xdex-history-item .h-threads-info-email, .xdex-history-item .h-threads-info-createdat, .xdex-history-item .h-threads-info-uid, .xdex-history-item .h-threads-info-id, .xdex-history-item .h-threads-info-reply-btn { margin-left:5px; } .xdex-history-delete, .xdex-post-history-delete, .xdex-history-reply-action { text-decoration:none; border:0; background:transparent; padding:0; font:inherit; cursor:pointer; } .xdex-history-delete:hover, .xdex-post-history-delete:hover, .xdex-history-reply-action:hover { text-decoration:underline; } .xdex-history-reply-action { color:#07d; } .xdex-history-reply-action:hover { color:#059; } .xdex-history-delete, .xdex-post-history-delete { position:absolute; top:-9px; right:10px; width:20px; height:20px; border:1px solid #a98f7a; border-radius:999px; background:#F0E0D6; line-height:16px; padding:0; font-size:14px; z-index:1; color:#800000; } /* 深色:历史卡片圆钮(删除/墓碑)与导入预览块走面板 token,浅色模式保持原样 */ :root.xdex-darkreader-active .xdex-history-delete, :root.xdex-darkreader-active .xdex-post-history-delete, :root.xdex-darkreader-active .xdex-history-tombstone-mark, :root.xdex-darkreader-active .xdex-post-history-tombstone-mark { background: var(--xdex-sp-fold-bg); color: var(--foreground); } /* 该元素背景/边框写在内联 style 上,必须 !important 才能压过 */ :root.xdex-darkreader-active #sp_fullExport_import_preview { background: var(--xdex-sp-panel-bg) !important; border-color: var(--xdex-sp-border) !important; } .xdex-history-footer { display:flex; align-items:center; flex-wrap:wrap; gap:0; clear:both; margin-top:8px; color:#777; font-size:12px; } .xdex-history-footer span + span::before { content:'·'; margin:0 6px; color:#777; } .xdex-history-po-label { color:#00FFCC; font-weight:bold; } .xdex-history-header { justify-content:space-between; margin-bottom:6px; } .xdex-history-meta { flex-wrap:wrap; font-size:12px; } .xdex-history-image, .xdex-post-history-image { display:block; width:112px; max-height:112px; margin:6px 10px 6px 0; overflow:hidden; float:left; } .xdex-history-image img, .xdex-post-history-image img { max-width:112px; max-height:112px; object-fit:contain; } .xdex-history-footer, .xdex-history-empty { clear:both; margin-top:8px; color:#777; font-size:12px; } /* 饼干 switch 动效 */ .xdex-pin-btn { transition: background-color 0.2s ease, border-color 0.2s ease; } .xdex-pin-btn:checked { transition: background-color 0.2s ease, border-color 0.2s ease; } </style> `); } if (!$('#sp_btn').length) { $('body').append( $(`<button id="sp_btn" type="button" title="X岛-EX 设置" aria-label="X岛-EX 设置"><img src="${SETTINGS_BUTTON_ICON_32}" srcset="${SETTINGS_BUTTON_ICON_32} 1x, ${SETTINGS_BUTTON_ICON_64} 2x" width="32" height="32" alt=""></button>`) .on('click',()=>{ this.syncInputs(); this.syncAuxiliaryControls(); if (typeof window.__xdexSyncDarkReaderTheme === 'function') window.__xdexSyncDarkReaderTheme(); $('#sp_cover').fadeIn(); spUpdate('maybeShowPendingUpdateDialogOnPanelOpen'); }) ); spUpdate('updateSettingsButtonBadge', spUpdate('getUpdateCheckState')); } const fold = (id,title,ph) => ` <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_${id}" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>${title}</span> <button id="btn_${id}" class="sp_save xdex-inv" data-id="${id}" style="margin-left:auto;padding:2px 8px;">保存</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <input id="${id}" style="width:100%;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="${ph}"> </div> </div>`; const checkboxItemStyle = 'width:50%; display:flex; align-items:center; gap:8px;'; const checkboxRowStyle = 'width:50%; display:flex; align-items:center; gap:8px;'; const quickReplyRowStyle = 'display:flex; align-items:center; gap:12px; margin:4px 0;'; const quickReplyColumnStyle = 'flex:1; display:flex; flex-direction:column; align-items:flex-start; gap:4px;'; const html = ` <style> .fixed-on { accent-color: #000; /* 勾选颜色黑色 */ } .fixed-on:disabled { opacity: 1; /* 不降低透明度 */ filter: none; /* 去掉可能的灰度滤镜 */ cursor: default; /* 鼠标变成普通箭头 */ } .xdex-switch { appearance:none; width:34px; height:18px; flex:0 0 34px; border:1px solid #a98f7a; border-radius:999px; background:#EE0000; cursor:pointer; position:relative; box-sizing:border-box; transition:background .18s ease,border-color .18s ease; } .xdex-switch::before { content:""; position:absolute; width:14px; height:14px; left:1px; top:1px; border-radius:50%; background:#FFFFEE; box-shadow:0 1px 2px rgba(0,0,0,.24); transition:transform .18s ease; } .xdex-switch:checked { background:#66CCFF; border-color:#7da6bf; } input.xdex-switch.fixed-on:checked:disabled { background:#00FFCC; border-color:#00b894; } .xdex-switch:checked::before { transform:translateX(16px); } /* 饼干偏好开关圆圈:当前饼干命中本串偏好→深青(#006666),未命中→红(#EE0000);未开启偏好不着色 */ .xdex-cookie-check-sw.xdex-cookie-pref-match::before { background:#006666; } .xdex-cookie-check-sw.xdex-cookie-pref-mismatch::before { background:#EE0000; } .xdex-switch:focus-visible { outline:2px solid #cc1105; outline-offset:2px; } .xdex-switch:disabled { opacity:1; cursor:default; } </style> <div id="sp_cover" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:9999;"> <div id="sp_panel" style=" position:relative;margin:40px auto;width:min(711px, calc(100vw - 32px)); max-height:calc(100vh - 80px);background:#FFFFEE;border-radius:8px; display:flex;flex-direction:column;box-shadow:0 2px 10px rgba(0,0,0,0.2);"> <div id="sp_panel_tab_slot" aria-label="设置面板模块"> <button type="button" class="sp_panel_tab" data-sp-module="settings"><span class="sp_panel_tab_icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span class="sp_panel_tab_label">设置</span></button> <button type="button" class="sp_panel_tab" data-sp-module="history"><span class="sp_panel_tab_icon"><svg viewBox="-1 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v5h5"></path><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"></path><path d="M12 7v5l4 2"></path></svg></span><span class="sp_panel_tab_label">浏览历史</span></button> <button type="button" class="sp_panel_tab" data-sp-module="posts"><span class="sp_panel_tab_icon"><svg viewBox="-0.8 0.3 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg></span><span class="sp_panel_tab_label">我的发言</span></button> <button type="button" class="sp_panel_tab" data-sp-module="feeds"><span class="sp_panel_tab_icon"><svg viewBox="0 -0.5 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></span><span class="sp_panel_tab_label">我的订阅</span></button> </div> <div id="sp_panel_views"> <div id="sp_module_settings" class="sp_panel_module active" data-sp-module-view="settings"> <div id="sp_panel_content" class="sp_panel_content" style="padding:18px;overflow-y:auto;flex:1;min-height:300px;box-sizing:border-box;"> <div id="sp_panel_title" style="margin:0 0 10px; position:relative; text-align:center;"> <span class="xdex-setting-title-easter-egg" style="font-size:20px; font-weight:bold; cursor:pointer;">X岛-EX</span> <div style="position:absolute; right:0; top:50%; transform:translateY(-50%); display:flex; align-items:center; gap:10px;"> <label for="sp_enableCustomDarkTheme" id="sp_dark_toggle_wrap" title="深色模式" style="display:inline-flex; align-items:center; cursor:pointer;"> <input type="checkbox" id="sp_enableCustomDarkTheme" role="switch" style="position:absolute;opacity:0;width:0;height:0;"> <span id="sp_dark_toggle_track" style="display:inline-flex;align-items:center;width:46px;height:24px;border-radius:12px;background:#fff;border:1px solid #ccc;transition:background .35s ease,border-color .35s ease;box-sizing:border-box;"> <span id="sp_dark_toggle_thumb" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);margin-left:3px;transition:transform .35s ease,background .35s ease;"> <svg id="sp_dark_icon_sun" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f39c12" stroke-width="2.4" stroke-linecap="round"><circle cx="12" cy="12" r="4.5" fill="#f39c12" stroke="none"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5 5l2.1 2.1M16.9 16.9L19 19M19 5l-2.1 2.1M7.1 16.9L5 19"/></svg> <svg id="sp_dark_icon_moon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ffd54f" stroke-width="2.2" stroke-linecap="round" style="display:none;"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5z" fill="#ffd54f" stroke="none"/></svg> </span> </span> </label> <a id="sp_version_link" href="javascript:void(0)" style="font-size:12px; color:#999; text-decoration:underline;">v2.1.0.1</a> </div> </div> <div id="sp_checkbox_container" style="display:flex;flex-wrap:wrap;"> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableCookieSwitch" class="xdex-switch" role="switch"><label for="sp_enableCookieSwitch"> 快捷切换饼干</label><input type="checkbox" id="sp_enableCookieConfirm" class="xdex-switch" role="switch"><label for="sp_enableCookieConfirm"> 二次确认饼干</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enablePaginationDuplication" class="xdex-switch" role="switch"><label for="sp_enablePaginationDuplication"> 添加页首页码</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_disableWatermark" class="xdex-switch" role="switch"><label for="sp_disableWatermark"> 关闭图片水印</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_updatePreviewCookie" class="xdex-switch" role="switch"><label for="sp_updatePreviewCookie"> 预览真实饼干</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_hideEmptyTitleEmail" class="xdex-switch" role="switch"><label for="sp_hideEmptyTitleEmail"> 隐藏无标题/无名氏/版规</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableExternalImagePreview" class="xdex-switch" role="switch"><label for="sp_enableExternalImagePreview"> 显示外部图床</label></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enableAutoCookieRefresh" class="xdex-switch" role="switch"><label for="sp_enableAutoCookieRefresh"> 自动刷新饼干</label><input type="checkbox" id="sp_enableAutoCookieRefreshToast" class="xdex-switch" role="switch"><label for="sp_enableAutoCookieRefreshToast"> toast提示</label></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enableSeamlessPaging" class="xdex-switch" role="switch"><label for="sp_enableSeamlessPaging"> 无缝翻页</label><input type="checkbox" id="sp_enableAutoSeamlessPaging" class="xdex-switch" role="switch" checked><label for="sp_enableAutoSeamlessPaging"> 自动翻页</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableHDImageAndLayoutFix" class="xdex-switch" role="switch"><label for="sp_enableHDImageAndLayoutFix"> 图片控件-布局调整</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableLinkBlank" class="xdex-switch" role="switch"><label for="sp_enableLinkBlank"> 新标签打开串</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableAutoUrlLinkify" class="xdex-switch" role="switch"><label for="sp_enableAutoUrlLinkify"> 自动识别链接</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableQuotePreview" class="xdex-switch" role="switch"><label for="sp_enableQuotePreview"> 优化引用弹窗</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_extendQuote" class="xdex-switch" role="switch"><label for="sp_extendQuote"> 拓展引用格式</label><input type="checkbox" id="sp_extendQuoteAvailabilityDetection" class="xdex-switch" role="switch"><label for="sp_extendQuoteAvailabilityDetection"> 可用性检测</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_toggleSidebar" class="xdex-switch" role="switch"><label for="sp_toggleSidebar"> 自动收起侧边栏</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableUpdateCheck" class="xdex-switch" role="switch"><label for="sp_enableUpdateCheck"> 检查更新</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableImageContextMenu" class="xdex-switch" role="switch"><label for="sp_enableImageContextMenu"> 图片菜单</label></div> <div style="${checkboxItemStyle}"><input type="checkbox" id="sp_enableThreadHistory" class="xdex-switch" role="switch"><label for="sp_enableThreadHistory"> 浏览历史</label></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enablePostHistory" class="xdex-switch" role="switch"><label for="sp_enablePostHistory"> 发言历史</label><input type="checkbox" id="sp_disableAutoQuote" class="xdex-switch" role="switch"><label for="sp_disableAutoQuote"> 关闭引用</label><select id="sp_postAfterAction" style="height:24px;"><option value="jump">发串后跳转</option><option value="refresh">发串后刷新</option></select></div> <!-- <div style="width:50%;"><label for="sp_占位"> </label></div> --> <!-- 以下是默认勾选项不可更改 --> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_interceptReplyForm" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_interceptReplyForm"> 拦截回复中间页</label><input type="checkbox" id="sp_interceptReplyFormAutoCompress" class="xdex-switch" role="switch"><label for="sp_interceptReplyFormAutoCompress"> 自动压缩图片</label></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_interceptReplyFormUnvcode" class="xdex-switch" role="switch"><label for="sp_interceptReplyFormUnvcode"> unvcode</label><input type="checkbox" id="sp_interceptReplyFormU200B" class="xdex-switch" role="switch"><label for="sp_interceptReplyFormU200B"> 零宽空格优先</label></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_updateReplyNumbers" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_updateReplyNumbers"> 当页回复编号</label><input type="hidden" name="sp_updateReplyNumbers" value="1"></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_replaceRightSidebar" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_replaceRightSidebar"> 扩展坞增强</label><select id="sp_dockDisplayMode" style="height:24px;"><option value="hover">隐藏</option><option value="fixed">固定</option></select><input type="hidden" name="sp_replaceRightSidebar" value="1"></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_kaomojiEnhancer" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_kaomojiEnhancer"> 颜文字拓展</label><select id="sp_kaomojiSort" style="height:24px;"><option value="default">默认</option><option value="recent">最近</option><option value="freq">常用</option></select><input type="hidden" name="sp_kaomojiEnhancer" value="1"></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_highlightPO" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_highlightPO"> 标记Po主</label><input type="hidden" name="sp_highlightPO" value="1"></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_applyFilters" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_applyFilters"> 标记/屏蔽-饼干/关键词</label><select id="sp_blockDisplayMode" style="height:24px;"><option value="fold">折叠</option><option value="hide">隐藏</option></select><input type="hidden" name="sp_applyFilters" value="1"></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_threadCookieWhitelistModeEnabled" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_threadCookieWhitelistModeEnabled"> 只看饼干</label><select id="sp_threadCookieWhitelistDisplayMode" style="height:24px;"><option value="fold">折叠</option><option value="hide">隐藏</option><option value="column">分栏</option></select><select id="sp_poAnnotationSideDisplayMode" style="height:24px;"><option value="collapse">收起</option><option value="expand">展开</option></select></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enhancePostFormLayout" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_enhancePostFormLayout"> 发串UI调整</label><input type="hidden" name="sp_enhancePostFormLayout" value="1"></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enhanceIsland" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_enhanceIsland"> 增强X岛匿名版</label><select id="sp_enableDraftMode" style="height:24px;"><option value="on">关闭草稿</option><option value="off">开启草稿</option></select><select id="sp_timeDisplayMode" style="height:24px;"><option value="relative">相对时间</option><option value="exact">精确时间</option></select><input type="hidden" name="sp_enhanceIsland" value="1"></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enablePostExpand" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_enablePostExpand"> 展开板块页长串</label><select id="sp_postExpandAllMode" style="height:24px;"><option value="collapse">全部收起</option><option value="expand">全部展开</option></select><input type="hidden" name="sp_enablePostExpand" value="1"></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_searchServiceBy4sY" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_searchServiceBy4sY"> 野生搜索酱</label><input type="hidden" name="sp_searchServiceBy4sY" value="1"></div> <div style="${checkboxRowStyle}"> <input type="checkbox" id="sp_enableImageHideMode" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_enableImageHideMode"> 图片显示模式</label> <select id="sp_applyImageHideMode" style="height:24px;"> <option value="default">默认</option> <option value="blur">模糊</option> <option value="noimage">无图</option> <option value="tips">Tips</option> </select> </div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enableFavoriteThreads" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_enableFavoriteThreads"> 常用串</label></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enableSubscriptionFeed" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_enableSubscriptionFeed"> 我的订阅</label><button type="button" id="sp_openDefaultSubscriptionFeed" class="xdex-edit-default-btn" style="margin-left:4px;" title="设置默认订阅号">${XDEX_ICON_GEAR}</button></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enableImageViewerMode" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_enableImageViewerMode"> 阅图模式</label><input type="checkbox" id="sp_imageViewerSeparators" class="xdex-switch" role="switch"><label for="sp_imageViewerSeparators"> 分隔线</label><button type="button" id="sp_openImageViewer" class="xdex-icon-btn" style="margin-left:4px;" title="打开阅图模式">${XDEX_ICON_IMAGE}</button></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_autoSelectReportReason" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_autoSelectReportReason"> 值班室优化</label></div> <div style="${checkboxRowStyle}"><input type="checkbox" id="sp_enableThreadExport" class="xdex-switch fixed-on" role="switch" checked disabled><label for="sp_enableThreadExport"> 导出串</label><button type="button" id="sp_openThreadExport" class="xdex-icon-btn" style="margin-left:4px;" title="打开导出窗口">${XDEX_EXPORT_ICON}</button></div> </div> <div style="margin-top:12px;"> <h3 id="sp_replyQuicklyOnBoardPage" style="margin:6px 0;">板块页快速回复默认设置</h3> <div style="${quickReplyRowStyle}"> <!-- 左侧:板块页默认模式 --> <div style="${quickReplyColumnStyle}"> <label for="sp_replyModeDefault" style="margin-bottom:4px;">板块页默认模式</label> <select id="sp_replyModeDefault" style="width:100%;"> <option value="发串">发串</option> <option value="回复">回复</option> </select> </div> <!-- 右侧:回复默认模式 --> <div style="${quickReplyColumnStyle}"> <label for="sp_replyExtraDefault" style="margin-bottom:4px;">回复默认模式</label> <select id="sp_replyExtraDefault" style="width:100%;"> <option value="临时">临时</option> <option value="连续">连续</option> </select> </div> </div> </div> <div style="margin-top:12px;"> <!-- 标记饼干(组) --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_marked,#btn_group_marked" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>标记饼干</span> <button id="btn_group_marked" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:auto;" title="添加分组">${XDEX_ICON_ADD_GROUP}</button> <button id="btn_sp_marked" class="sp_save xdex-btn-hidden xdex-icon-btn" data-id="sp_marked" style="margin-left:4px;" title="保存">${XDEX_ICON_SAVE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div id="marked-inputs-container"></div> </div> </div> <!-- 屏蔽饼干(组,含备注) --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_blocked,#btn_group_blocked" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>屏蔽饼干</span> <button id="btn_group_blocked" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:auto;" title="添加分组">${XDEX_ICON_ADD_GROUP}</button> <button id="btn_sp_blocked" class="sp_save xdex-btn-hidden xdex-icon-btn" data-id="sp_blocked" style="margin-left:4px;" title="保存">${XDEX_ICON_SAVE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div id="blocked-inputs-container"></div> </div> </div> <!-- 只看饼干(组,按串号) --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_threadCookieWhitelist,#btn_group_threadCookieWhitelist" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>只看饼干</span> <button id="btn_group_threadCookieWhitelist" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:auto;" title="添加分组">${XDEX_ICON_ADD_GROUP}</button> <button id="btn_sp_threadCookieWhitelist" class="sp_save xdex-btn-hidden xdex-icon-btn" data-id="sp_threadCookieWhitelist" style="margin-left:4px;" title="保存">${XDEX_ICON_SAVE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div id="thread-cookie-whitelist-inputs-container"></div> </div> </div> <!-- 屏蔽关键词(组) --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_blockedKeywords,#btn_group_blockedKeywords" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>屏蔽关键词</span> <button id="btn_group_blockedKeywords" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:auto;" title="添加分组">${XDEX_ICON_ADD_GROUP}</button> <button id="btn_sp_blockedKeywords" class="sp_save xdex-btn-hidden xdex-icon-btn" data-id="sp_blockedKeywords" style="margin-left:4px;" title="保存">${XDEX_ICON_SAVE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div id="blocked-keyword-inputs-container"></div> <div style="font-size:12px;color:#888;text-align:center;">默认逗号分隔关键词;开启“正则”后整组按正则匹配(不再逗号切分)</div> </div> </div> <!-- 常用串 --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_favoriteThreads,#btn_group_favoriteThreads" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>常用串</span> <button id="btn_group_favoriteThreads" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:auto;" title="添加常用串">${XDEX_ICON_ADD_GROUP}</button> <button id="btn_sp_favoriteThreads" class="sp_save xdex-btn-hidden xdex-icon-btn" data-id="sp_favoriteThreads" style="margin-left:4px;" title="保存">${XDEX_ICON_SAVE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div id="favorite-thread-inputs-container"></div> <!-- <div style="font-size:12px;color:#888;text-align:center;">备注可选;可填写 8 位串号或串链接,保存后统一为主串链接</div> --> </div> </div> <!-- 我的订阅 --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_subscriptionFeeds,#btn_group_subscriptionFeeds" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>我的订阅</span> <button id="btn_group_subscriptionFeeds" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:auto;" title="添加订阅号">${XDEX_ICON_ADD_GROUP}</button> <button id="btn_sp_subscriptionFeeds" class="sp_save xdex-btn-hidden xdex-icon-btn" data-id="sp_subscriptionFeeds" style="margin-left:4px;" title="保存">${XDEX_ICON_SAVE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div id="subscription-feed-inputs-container"></div> </div> </div> <!-- 设置 导入/导出 --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_importExport" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>设置 导入/导出</span> <button id="btn_sp_importExport" class="sp_save xdex-btn-hidden xdex-icon-btn" data-id="sp_importExport" style="margin-left:auto;" title="应用">${XDEX_ICON_SAVE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div style="display:flex;gap:8px;margin-bottom:8px;"> <button id="sp_importClipboard" style="flex:1;padding:4px 8px;font-size:13px;cursor:pointer;">从剪贴板导入</button> <button id="sp_exportClipboard" style="flex:1;padding:4px 8px;font-size:13px;cursor:pointer;">导出到剪贴板</button> </div> <div style="display:flex;gap:8px;margin-bottom:8px;"> <button id="sp_importFile" style="flex:1;padding:4px 8px;font-size:13px;cursor:pointer;">从文件导入</button> <button id="sp_exportFile" style="flex:1;padding:4px 8px;font-size:13px;cursor:pointer;">导出为文件</button> </div> <div style="font-size:12px;color:#888;text-align:center;">导入将覆盖当前全部配置,建议先导出备份</div> </div> </div> <!-- 使用数据 导入/导出 --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_fullExport_reset,#btn_sp_fullExport_export,#btn_sp_fullExport_import" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>使用数据 导入/导出</span> <button id="btn_sp_fullExport_reset" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:auto;" title="重置所选项目">${XDEX_ICON_RESET}</button> <button id="btn_sp_fullExport_export" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:4px;" title="导出为文件">${XDEX_ICON_EXPORT_FILE}</button> <button id="btn_sp_fullExport_import" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:4px;" title="从文件导入">${XDEX_ICON_IMPORT_FILE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div style="font-size:12px;color:#666;margin-bottom:6px;"> 用于备份或迁移脚本/扩展运行产生的数据,支持按类别导出与导入。默认全选。 </div> <div style="display:flex;flex-wrap:wrap;gap:6px 16px;margin-bottom:6px;"> <div style="display:flex;align-items:center;gap:4px;"> <input type="checkbox" id="sp_fullExport_settings" class="xdex-switch" role="switch" checked> <label for="sp_fullExport_settings">设置</label> </div> <div style="display:flex;align-items:center;gap:4px;"> <input type="checkbox" id="sp_fullExport_threadHistory" class="xdex-switch" role="switch" checked> <label for="sp_fullExport_threadHistory">浏览历史</label> </div> <div style="display:flex;align-items:center;gap:4px;"> <input type="checkbox" id="sp_fullExport_postHistory" class="xdex-switch" role="switch" checked> <label for="sp_fullExport_postHistory">发言历史</label> </div> <div style="display:flex;align-items:center;gap:4px;"> <input type="checkbox" id="sp_fullExport_drafts" class="xdex-switch" role="switch" checked> <label for="sp_fullExport_drafts">草稿</label> </div> <div style="display:flex;align-items:center;gap:4px;"> <input type="checkbox" id="sp_fullExport_kaomojiStats" class="xdex-switch" role="switch" checked> <label for="sp_fullExport_kaomojiStats">颜文字统计</label> </div> <div style="display:flex;align-items:center;gap:4px;"> <input type="checkbox" id="sp_fullExport_cookiePrefs" class="xdex-switch" role="switch" checked> <label for="sp_fullExport_cookiePrefs">串内饼干偏好</label> </div> <div style="display:flex;align-items:center;gap:4px;"> <input type="checkbox" id="sp_fullExport_webdav" class="xdex-switch" role="switch" checked> <label for="sp_fullExport_webdav">WebDAV 配置</label> </div> </div> <div id="sp_fullExport_import_preview" style="display:none;margin-top:8px;padding:6px 8px;border:1px dashed #aaa;border-radius:6px;background:#FFFFEE;"></div> </div> </div> <!-- WebDAV 备份/同步 --> <div class="sp_fold" style="border:1px solid #eee;margin:6px 0;background:#F0E0D6;"> <div class="sp_fold_head" data-btn="#btn_sp_webdavSave,#btn_webdavSync,.xdex-webdav-head-actions" style="display:flex;align-items:center;padding:6px 8px;background:#F0E0D6;cursor:pointer;"> <span>WebDAV 备份/同步</span> <span id="sp_webdavLastSyncLabel" style="flex:1;min-width:0;margin:0 8px;text-align:center;color:#666;font-size:12px;">${webdavPanelField('lastSync')}</span> <div class="xdex-webdav-head-actions xdex-inv" style="display:flex;align-items:center;gap:4px;margin-left:0;" title="自动同步策略: · 所有页面共享一个计时器,约1小时触发一次 · 到点没有页面同步时,打开新页面会立即补一次 · 点击手动同步后计时器会重置 · 开启开关后立即同步一次(60秒内不重复,内容无变化也不重复) 同步策略: · 远端较新则下载合并 · 设置冲突时自动保留更合理的版本(本地为默认则采用远端,已个性化则保留本地) · WebDAV 配置不随同步覆盖"> <label for="sp_webdavAutoSync" style="font-size:12px;">自动同步</label> <input type="checkbox" id="sp_webdavAutoSync" class="xdex-switch" role="switch" ${webdavPanelField('autoSync')}> </div> <button id="btn_webdavSync" type="button" class="xdex-btn-hidden xdex-icon-btn" style="margin-left:4px;" title="手动同步">${XDEX_ICON_SYNC}</button> <button id="btn_sp_webdavSave" class="sp_save xdex-btn-hidden xdex-icon-btn" data-id="sp_webdavSave" style="margin-left:4px;" title="保存">${XDEX_ICON_SAVE}</button> </div> <div class="sp_fold_body" style="display:none;padding:8px 10px;background:#F0E0D6;"> <div style="display:flex;gap:4px;align-items:center;"> <input id="sp_webdavUrl" type="text" value="${webdavPanelField('url')}" placeholder="WebDAV 链接(目录,如 https://dav.jianguoyun.com/dav/xdex)" style="flex:1;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;"> <button id="btn_webdavCheck" type="button" class="xdex-icon-btn" title="检查连接">${XDEX_ICON_PLUG}</button> </div> <div style="display:flex;gap:4px;align-items:center;"> <input id="sp_webdavUsername" type="text" value="${webdavPanelField('username')}" placeholder="账户" style="flex:1;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;"> <span class="xdex-icon-btn" style="opacity:0;pointer-events:none;" aria-hidden="true"></span> </div> <div style="display:flex;gap:4px;align-items:center;"> <input id="sp_webdavPassword" type="password" value="${webdavPanelField('password')}" placeholder="密码" style="flex:1;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;"> <button id="btn_webdavTogglePassword" type="button" class="xdex-icon-btn" title="显示密码">${XDEX_ICON_EYE}</button> </div> <div id="sp_webdavStatus" style="font-size:12px;color:#666;white-space:pre-wrap;margin-top:8px;"></div> </div> </div> </div> </div> </div> <div id="sp_module_history" class="sp_panel_module" data-sp-module-view="history"> <div class="sp_panel_content"> <div id="sp_history_content"> <div id="sp_history_title" style="margin:0 0 10px; position:relative; text-align:center;"> <span style="font-size:20px; font-weight:bold;">浏览历史</span> </div> <div class="xdex-history-toolbar" id="sp_history_toolbar"> <input id="sp_history_search" type="search" autocomplete="off" placeholder="搜索标题、名称、正文、串号等关键词;高级检索见后方 ?"> <span id="sp_history_count" class="xdex-history-count">0 条</span> <select id="sp_history_sort" aria-label="浏览历史排序"> <option value="last-desc">最近访问优先</option> <option value="last-asc">最早访问优先</option> <option value="visits-desc">访问次数最多</option> <option value="visits-asc">访问次数最少</option> <option value="page-desc">最高页码优先</option> </select> <button id="sp_history_clear" type="button" style="padding:6px 10px;">清空</button> <button id="sp_history_recycle" type="button" class="xdex-recycle-btn" title="回收站" aria-label="回收站"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg> <span id="sp_history_recycle_badge" class="xdex-recycle-badge" hidden>0</span> </button> </div> <div id="sp_history_recycle_bar" class="xdex-recycle-bar" hidden> <button id="sp_history_recycle_back" type="button" class="xdex-recycle-back" title="返回浏览历史">← 返回</button> <span class="xdex-recycle-bar-title">回收站 <span id="sp_history_recycle_barcount" class="xdex-history-count">0 条</span></span> <select id="sp_history_recycle_sort" class="xdex-recycle-sort" aria-label="回收站排序"> <option value="expiring">即将删除优先</option> <option value="recent">最近删除优先</option><option value="due24h">24小时内到期</option> </select> <button id="sp_history_recycle_empty" type="button" class="xdex-recycle-empty-btn" disabled>清空回收站</button> </div> <div id="sp_history_results"></div> </div> </div> </div> <div id="sp_module_posts" class="sp_panel_module" data-sp-module-view="posts"> <div class="sp_panel_content"> <div id="sp_posts_content"> <div id="sp_posts_title" style="margin:0 0 10px; position:relative; text-align:center;"> <span style="font-size:20px; font-weight:bold;">我的发言</span> </div> <div class="xdex-history-toolbar" id="sp_posts_toolbar"> <input id="sp_posts_search" type="search" autocomplete="off" placeholder="搜索标题、名称、正文、串号等关键词;高级检索见后方 ?"> <span id="sp_posts_count" class="xdex-history-count">0 条</span> <button id="sp_posts_clear" type="button" style="padding:6px 10px;">清空</button> <button id="sp_posts_recycle" type="button" class="xdex-recycle-btn" title="回收站" aria-label="回收站"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg> <span id="sp_posts_recycle_badge" class="xdex-recycle-badge" hidden>0</span> </button> </div> <div id="sp_posts_recycle_bar" class="xdex-recycle-bar" hidden> <button id="sp_posts_recycle_back" type="button" class="xdex-recycle-back" title="返回我的发言">← 返回</button> <span class="xdex-recycle-bar-title">回收站 <span id="sp_posts_recycle_barcount" class="xdex-history-count">0 条</span></span> <select id="sp_posts_recycle_sort" class="xdex-recycle-sort" aria-label="回收站排序"> <option value="expiring">即将删除优先</option> <option value="recent">最近删除优先</option><option value="due24h">24小时内到期</option> </select> <button id="sp_posts_recycle_empty" type="button" class="xdex-recycle-empty-btn" disabled>清空回收站</button> </div> <div id="sp_posts_type_buttons" class="xdex-post-history-type-buttons"> <button type="button" data-post-history-type="thread">我的主题</button> <button type="button" data-post-history-type="reply" class="active">我的回复</button> </div> <div class="xdex-posts-add-row" style="display:flex;gap:6px;margin-bottom:8px;"> <input id="sp_posts_manual_add_input" type="search" placeholder="No.67024789、67024789、https://nmbxd1.com/t/67024789、67024789?r=68811442&page=23" style="flex:1;padding:4px 8px;font-size:12px;border:1px solid var(--xdex-sp-border, #ccc);border-radius:6px;background:var(--xdex-sp-panel-bg, #fff);"> <button id="sp_posts_manual_add_btn" type="button" style="padding:4px 10px;font-size:13px;">手动添加</button> </div> <div id="sp_posts_results"></div> </div> </div> </div> <div id="sp_module_feeds" class="sp_panel_module" data-sp-module-view="feeds"> <div class="sp_panel_content"> <div id="sp_feeds_content"> <div id="sp_feeds_title" style="margin:0 0 10px; position:relative; text-align:center;"> <span style="font-size:20px; font-weight:bold;">我的订阅</span> </div> <div class="xdex-history-toolbar" style="flex-wrap:wrap;gap:6px;"> <div class="xdex-feed-selector-wrap" style="flex:1 1 auto;min-width:0;position:relative;"> <select id="sp_feeds_selector" aria-hidden="true" tabindex="-1" style="position:absolute;opacity:0;pointer-events:none;width:0;height:0;"></select> <div id="sp_feeds_selector_display" class="xdex-feed-selector-display" role="combobox" aria-haspopup="listbox" aria-expanded="false"> <span class="xdex-feed-display-desc"></span> <span class="xdex-feed-display-uuid"></span> <svg class="xdex-feed-chevron" width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M8 2.5L4 6L8 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> </div> <div id="sp_feeds_selector_dropdown" class="xdex-feed-selector-dropdown" role="listbox" style="display:none;"></div> </div> <input id="sp_feeds_page_input" type="number" min="1" placeholder="页码" size="4" style="flex:0 0 auto;width:4em;padding:4px 6px;"> <button id="sp_feeds_page_jump" type="button" style="padding:4px 8px;">跳转</button> </div> <div style="display:flex;gap:6px;margin-bottom:8px;"> <input id="sp_feeds_bulk_add_input" type="search" placeholder="输入串号,多个用逗号分隔,如 67024789,66994128" autocomplete="off" style="flex:1;padding:4px 8px;font-size:12px;border:1px solid var(--xdex-sp-border, #ccc);border-radius:6px;background:var(--xdex-sp-panel-bg, #fff);"> <button id="sp_feeds_bulk_add_btn" type="button" style="padding:4px 10px;font-size:13px;">批量添加</button> <button id="sp_feeds_export_clipboard" type="button" style="padding:4px 10px;font-size:13px;">导出串号</button> </div> <div id="sp_feeds_results"></div> </div> </div> </div> </div> <div id="sp_panel_footer" style="padding:10px 18px;display:flex;align-items:center;justify-content:space-between;position:relative;border-top:1px solid #eee;background:#FFFFEE;"> <div class="sp_panel_links" style="display:flex;align-items:center;gap:8px;"> <a data-update-channel="thread" data-thread-id="67024789" href="https://www.nmbxd1.com/t/67024789" target="_blank" rel="noopener" title="更新串内">串内</a> <a data-update-channel="greasyfork" href="https://greasyfork.org/zh-CN/scripts/531005-x%E5%B2%9B-ex" target="_blank" rel="noopener" title="GreasyFork渠道,需代理">GreasyFork</a> <a data-update-channel="github" href="https://github.com/SayaGoodBye/nmbxd-EX" target="_blank" rel="noopener" title="Github">Github</a> <a data-update-channel="scriptcat" href="https://scriptcat.org/zh-CN/script-show-page/6289" target="_blank" rel="noopener" title="脚本猫渠道,可直连">ScriptCat</a> <a data-update-channel="baidupan" href="https://pan.baidu.com/s/1-ELWglsTXG8jK5S6WwqtsQ?pwd=k8zf" target="_blank" rel="noopener" title="百度网盘备份">百度网盘</a> <a data-update-channel="release" href="https://fastly.jsdelivr.net/gh/SayaGoodBye/nmbxd-EX@latest/nmbxd-EX.user.js" target="_blank" rel="noopener" title="最新发布版用户脚本镜像">Release</a> <a data-update-channel="dev" href="https://fastly.jsdelivr.net/gh/SayaGoodBye/nmbxd-EX@dev/nmbxd-EX.user.js" target="_blank" rel="noopener" title="最新开发版用户脚本镜像">Dev</a> <!-- 镜像服务:https://docs.suanlix.cn/github.html --> <a data-update-channel="extension" href="https://github.ikgy.top/https://github.com/SayaGoodBye/nmbxd-EX/releases/latest/download/nmbxd-EX-Extension.zip" target="_blank" rel="noopener" title="最新发布版Chromium内核浏览器拓展">拓展</a> </div> <div id="sp_feeds_pager" style="display:none;position:absolute;left:50%;transform:translateX(-50%);align-items:center;gap:8px;"> <button id="sp_feeds_prev" type="button" style="padding:4px 10px;">上一页</button> <span id="sp_feeds_page_label" style="font-size:18px;color:#666;">第1页</span> <button id="sp_feeds_next" type="button" style="padding:4px 10px;">下一页</button> </div> <div class="sp_panel_actions" style="display:flex;align-items:center;gap:10px;"> <button id="sp_apply" style="padding:6px 10px;">保存并刷新</button> <button id="sp_close" style="padding:6px 10px;">关闭</button> </div> </div> </div> </div>`; $('#sp_cover').remove(); $('body').append(html); syncFavoriteThreadsLinks(); installSettingPanelEasterEgg(document); function setSettingsPanelModule(moduleName) { // 离开对应模块时退出其回收站视图,回到正常列表态 if (moduleName !== 'history') setThreadHistoryRecycleMode(false); if (moduleName !== 'posts') setPostHistoryRecycleMode(false); const $nextView = $(`#sp_panel_views [data-sp-module-view="${moduleName}"]`); const nextModule = $nextView.length ? moduleName : 'settings'; $('#sp_panel_tab_slot .sp_panel_tab').removeClass('active') .filter(`[data-sp-module="${nextModule}"]`).addClass('active'); $('#sp_panel_views .sp_panel_module').removeClass('active').css('display', 'none'); const $activeModule = $('#sp_panel_views .sp_panel_module') .filter(`[data-sp-module-view="${nextModule}"]`) .addClass('active') .css({ display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: (nextModule === 'history' || nextModule === 'posts' || nextModule === 'feeds') ? '300px' : '0' }); if (nextModule === 'history' || nextModule === 'posts' || nextModule === 'feeds') { $activeModule.find('.sp_panel_content').css({ display: 'block', flex: '1 1 auto', minHeight: '300px', overflowY: 'auto', boxSizing: 'border-box' }); $activeModule.find('#sp_history_content,#sp_posts_content,#sp_feeds_content').css({ display: 'block' }); $activeModule.find('#sp_history_results,#sp_posts_results,#sp_feeds_results').css({ display: 'block', minHeight: '40px' }); } $('#sp_panel_footer .sp_panel_links').show(); $('#sp_panel_footer .sp_panel_links a').toggle(nextModule === 'settings'); $('#sp_apply').toggle(nextModule === 'settings'); $('#sp_feeds_pager').toggle(nextModule === 'feeds'); updateThreadHistoryDebugState({ lastPanelModule: nextModule, lastPanelState: { requested: moduleName, resolved: nextModule, footerLinksVisible: $('#sp_panel_footer .sp_panel_links').is(':visible'), applyVisible: $('#sp_apply').is(':visible'), historyResults: !!document.getElementById('sp_history_results'), historyResultsHeight: document.getElementById('sp_history_results')?.offsetHeight || 0, historyModuleDisplay: getComputedStyle(document.getElementById('sp_module_history') || document.body).display, historyContentDisplay: getComputedStyle(document.getElementById('sp_history_content') || document.body).display, historyPanelContentHeight: document.querySelector('#sp_module_history .sp_panel_content')?.offsetHeight || 0, at: new Date().toISOString() } }); logThreadHistory('panel module switched', threadHistoryDebugState.lastPanelState); logThreadHistoryFlat('panel module switched flat', threadHistoryDebugState.lastPanelState); // 进入浏览/发言历史 tab 时自动聚焦搜索框: // 用户可直接键入搜索;焦点从搜索框出发,Tab 仍按序进入列表(键盘移动列表不受干扰)。 // focus({preventScroll:true}) 避免面板内容区被滚动到顶部。 if (nextModule === 'history' || nextModule === 'posts') { const searchInput = document.getElementById(nextModule === 'history' ? 'sp_history_search' : 'sp_posts_search'); if (searchInput) { try { searchInput.focus({ preventScroll: true }); } catch (e) { try { searchInput.focus(); } catch (_e) {} } } } } $('#sp_panel_tab_slot').off('click', '[data-sp-module]').on('click', '[data-sp-module]', (e) => { e.preventDefault(); logThreadHistory('panel tab clicked', { module: $(e.currentTarget).data('spModule') }); const clickedModule = $(e.currentTarget).data('spModule'); // 功能关闭时进入对应 tab:error toast 提示不再接受新数据写入(数据仍可查看) if (clickedModule === 'history' && !isThreadHistoryEnabledNow()) { toast('浏览历史已关闭:不会记录新的浏览数据,已有数据仍可查看', 0, { type: 'error', key: 'thread-history-disabled' }); } else if (clickedModule === 'posts' && !isPostHistoryEnabledNow()) { toast('发言历史已关闭:不会自动记录新的发言数据,已有数据仍可查看', 0, { type: 'error', key: 'post-history-disabled' }); } setSettingsPanelModule(clickedModule); if ($(e.currentTarget).data('spModule') === 'history') renderThreadHistoryModuleSoon(); if ($(e.currentTarget).data('spModule') === 'posts') renderPostHistoryModuleSoon(); if ($(e.currentTarget).data('spModule') === 'feeds') renderSubscriptionFeedModule(); }); $('#sp_panel_tab_slot').off('mouseenter mouseleave', '.sp_panel_tab') .on('mouseenter', '.sp_panel_tab', (e) => { $(e.currentTarget).addClass('is-hover'); }) .on('mouseleave', '.sp_panel_tab', (e) => { $(e.currentTarget).removeClass('is-hover'); }); setSettingsPanelModule('settings'); bindThreadHistoryModuleEvents(); bindThreadHistoryLiveSync(); bindPostHistoryModuleEvents(); bindPostHistoryLiveSync(); bindSubscriptionFeedModuleEvents(); // 浏览/发言历史改为首次进入对应 tab 时再渲染,避免 init 阶段双模块预构建卡顿 // 折叠头:统一控制 $('.sp_fold_head').off('click').on('click', function(){ // WebDAV 头部的自动同步开关与保存按钮等控件不触发折叠 if ($(this).closest('.xdex-webdav-head-actions').length || this.id === 'btn_sp_webdavSave') return; const $head = $(this); $head.next('.sp_fold_body').slideToggle(150); const btns = ($head.data('btn') || '').split(','); btns.forEach(sel => { const $el = $(sel); // 图标按钮用 xdex-btn-hidden(display:none),文本按钮沿用 xdex-inv(透明热区) $el.toggleClass($el.hasClass('xdex-icon-btn') ? 'xdex-btn-hidden' : 'xdex-inv'); }); }); // 控件交互独立于折叠: 阻止冒泡到 .sp_fold_head $('#sp_webdavAutoSync, label[for="sp_webdavAutoSync"], #btn_sp_webdavSave').off('click.xdex-webdav-fold-guard').on('click.xdex-webdav-fold-guard', function (e) { e.stopPropagation(); }); // 同步已有配置 & 默认折叠 this.syncInputs(); const reloadRequiredSettingKeys = [ 'enableCookieSwitch', 'disableWatermark', 'enablePaginationDuplication', 'updatePreviewCookie', 'hideEmptyTitleEmail', 'enableExternalImagePreview', 'enableUpdateCheck', 'enableAutoCookieRefresh', 'enableAutoCookieRefreshToast', 'enableAutoSeamlessPaging', 'enableHDImageAndLayoutFix', 'enableImageContextMenu', 'enableLinkBlank', 'enableAutoUrlLinkify', 'enableQuotePreview', 'toggleSidebar', 'enableCustomDarkTheme' ]; // 即时保存项:从"刷新提示"清单移出(change 即保存),但「保存并刷新」按钮仍须收集它们, // 保证无论用户是否触发过 change,点击按钮后所有面板修改都在刷新后生效 const immediateSaveSettingKeys = [ 'enableCookieConfirm', 'interceptReplyFormUnvcode', 'interceptReplyFormU200B', 'interceptReplyFormAutoCompress', 'disableAutoQuote', 'extendQuote', 'extendQuoteAvailabilityDetection', 'enableSeamlessPaging' ]; const collectReloadRequiredSettingsFromPanel = () => { reloadRequiredSettingKeys.forEach(k => { this.state[k] = $('#sp_' + k).is(':checked'); }); immediateSaveSettingKeys.forEach(k => { this.state[k] = $('#sp_' + k).is(':checked'); }); // 深色模式:布尔开关 → light/dark 语义值 this.state.enableCustomDarkTheme = $('#sp_enableCustomDarkTheme').is(':checked') ? 'dark' : 'light'; // 固定启用:不受面板勾选状态影响 this.state.enableImageHideMode = true; }; // 设置写入失败会造成内存与持久化状态失步且完全无感知;留一次告警痕迹 // 标志挂 window:本方法可被多次执行,局部变量会随每次重开面板而复位 const warnSettingSaveFailed = (where, e) => { if (window.__xdexSettingSaveWarned) return; window.__xdexSettingSaveWarned = true; console.warn('[X岛-EX] 设置写入失败,刷新后该设置可能丢失(' + where + ')', e); }; const saveReloadRequiredSettingsImmediately = () => { collectReloadRequiredSettingsFromPanel(); try { GM_setValue(this.key, this.state); // 深色模式等即时生效项:保存后立即同步主题(无需刷新页面) if (typeof window.__xdexSyncDarkReaderTheme === 'function') window.__xdexSyncDarkReaderTheme(); toast('设置已保存,刷新后生效', 900, { queue: false, key: 'settings-saved' }); } catch (e) { warnSettingSaveFailed('需刷新项即时保存', e); } }; const reloadRequiredSettingSelector = reloadRequiredSettingKeys.map(k => '#sp_' + k).join(','); $(reloadRequiredSettingSelector) .off('change.xdexReloadSettingSave') .on('change.xdexReloadSettingSave', saveReloadRequiredSettingsImmediately); // 历史开关:即时生效(无需刷新)——更新 state + 持久化 + toast + 重渲染侧边栏入口 const applyHistoryToggleImmediately = (key, label) => { const checked = $('#sp_' + key).is(':checked'); this.state[key] = checked; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed(label, e); } toast(label + (checked ? '已开启' : '已关闭'), 1400, { queue: false, key: 'history-toggle' }); try { renderFavoriteThreadsMenu(); } catch (e) {} }; $('#sp_enableThreadHistory').off('change.xdexHistoryToggle').on('change.xdexHistoryToggle', () => { applyHistoryToggleImmediately('enableThreadHistory', '浏览历史'); }); $('#sp_enablePostHistory').off('change.xdexHistoryToggle').on('change.xdexHistoryToggle', () => { applyHistoryToggleImmediately('enablePostHistory', '发言历史'); }); // ★ 发送链开关:即时保存(消费点每次从 GM 重读:doSubmit / finishSubmitImageProcessing) // ——无需刷新,下次发送即生效;跨页同步由 GM 存储监听自然完成。 const saveSendChainToggleImmediately = (key, label) => { const checked = $('#sp_' + key).is(':checked'); this.state[key] = checked; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed(label, e); } toast(label + (checked ? '已开启' : '已关闭') + '(下次发送生效)', 1400, { queue: false, key: 'send-chain-toggle' }); }; const sendChainToggleLabels = { interceptReplyFormUnvcode: 'unvcode', interceptReplyFormU200B: '零宽空格优先', interceptReplyFormAutoCompress: '自动压缩图片' }; Object.keys(sendChainToggleLabels).forEach((key) => { $('#sp_' + key).off('change.xdexSendChainToggle').on('change.xdexSendChainToggle', () => { saveSendChainToggleImmediately(key, sendChainToggleLabels[key]); }); }); // ★ 发送前确认(串内饼干使用情况):即时保存 + 运行时注入/清理检查区 $('#sp_enableCookieConfirm').off('change.xdexCookieConfirmToggle').on('change.xdexCookieConfirmToggle', () => { const checked = $('#sp_enableCookieConfirm').is(':checked'); this.state.enableCookieConfirm = checked; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('发送前确认', e); } try { if (checked && this.state.enableCookieSwitch) { const tid = typeof PageType !== 'undefined' && PageType.getThreadId ? PageType.getThreadId(false) : ''; const shortTid = tid ? String(tid).slice(0, 8) : ''; if (shortTid) { if (typeof initThreadCookiePref === 'function') initThreadCookiePref(shortTid); if (typeof injectCookieCheckSwitch === 'function') injectCookieCheckSwitch(shortTid); } } else { if (typeof clearCookieCheckSwitch === 'function') clearCookieCheckSwitch(); } } catch (e) {} toast('发送前确认' + (checked ? '已开启' : '已关闭') + '(下次发送生效)', 1400, { queue: false, key: 'cookie-confirm-toggle' }); }); // ★ 快速回复默认模式:即时保存(消费点读 SettingPanel.state:打开表单/切换模式时生效) const saveReplyDefaultImmediately = (key, label, isExtra) => { const val = $('#sp_' + key).val(); this.state[key] = val; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed(label, e); } toast(label + '已保存:' + val, 1400, { queue: false, key: 'reply-default-toggle' }); }; $('#sp_replyModeDefault').off('change.xdexReplyDefault').on('change.xdexReplyDefault', () => { saveReplyDefaultImmediately('replyModeDefault', '板块页默认模式', false); }); $('#sp_replyExtraDefault').off('change.xdexReplyDefault').on('change.xdexReplyDefault', () => { saveReplyDefaultImmediately('replyExtraDefault', '回复默认模式', true); }); // ★ A 类即时项:消费点运行时读 GM/state,改完即生效 // 关闭引用(载入编辑判定读 getFilterConfig(),每次读 GM)——完全即时 $('#sp_disableAutoQuote').off('change.xdexImmediateToggle').on('change.xdexImmediateToggle', () => { const checked = $('#sp_disableAutoQuote').is(':checked'); this.state.disableAutoQuote = checked; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('关闭引用', e); } toast('关闭引用' + (checked ? '已开启' : '已关闭') + '(下次插入引用号时生效)', 1400, { queue: false, key: 'immediate-toggle' }); }); // 引用号拓展 + 可用性检测:getQuoteAvailabilityState() 每次读最新 state → 新内容即时生效; // 开启时对当前页面重跑一次(幂等);关闭时当前页已有拓展保留至刷新(无文本备份,不可逆) const applyQuoteTogglesImmediately = (key, label) => { const checked = $('#sp_' + key).is(':checked'); this.state[key] = checked; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed(label, e); } try { if (checked && typeof extendQuote === 'function') extendQuote(document); else if (typeof refreshQuoteAvailability === 'function') refreshQuoteAvailability(document); } catch (e) {} toast(label + (checked ? '已开启(已重新渲染当前页面引用号)' : '已关闭(当前页已有拓展将保留至刷新)'), 1400, { queue: false, key: 'immediate-toggle' }); }; $('#sp_extendQuote').off('change.xdexImmediateToggle').on('change.xdexImmediateToggle', () => { applyQuoteTogglesImmediately('extendQuote', '引用号拓展'); }); $('#sp_extendQuoteAvailabilityDetection').off('change.xdexImmediateToggle').on('change.xdexImmediateToggle', () => { applyQuoteTogglesImmediately('extendQuoteAvailabilityDetection', '引用号可用性检测'); }); // 无缝翻页:右栏「下一页」按钮路径(isSeamlessPagingEnabled 实时读 state)即时生效; // 触底观察器启动期装载,关闭本次不卸载(保留既有行为,toast 说明) $('#sp_enableSeamlessPaging').off('change.xdexImmediateToggle').on('change.xdexImmediateToggle', () => { const checked = $('#sp_enableSeamlessPaging').is(':checked'); this.state.enableSeamlessPaging = checked; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('无缝翻页', e); } toast('无缝翻页' + (checked ? '已开启(下一页按钮即时生效)' : '已关闭(触底观察器保留至刷新)'), 1400, { queue: false, key: 'immediate-toggle' }); }); // 深色开关:切换时先加页面过渡类保证平滑,样式由 syncInputs/save 回调接管 $('#sp_enableCustomDarkTheme') .off('change.xdexDarkToggle') .on('change.xdexDarkToggle', function () { try { document.documentElement.classList.add('xdex-theme-anim'); syncDarkToggleVisual(); setTimeout(() => { try { document.documentElement.classList.remove('xdex-theme-anim'); } catch (e) {} }, 500); } catch (e) {} }); // 图片隐藏模式:即时切换并即时应用(无需点“应用更改”) const applyImageHideModeImmediately = () => { const mode = $('#sp_applyImageHideMode').val() || 'default'; // 固定启用,仅切换具体模式 this.state.enableImageHideMode = true; this.state.applyImageHideMode = mode; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('图片隐藏模式', e); } if (typeof applyImageHideMode === 'function') { applyImageHideMode(mode, document); } }; // 扩展坞显示模式:即时切换并即时应用(固定/隐藏) const applyDockDisplayModeImmediately = () => { const mode = $('#sp_dockDisplayMode').val() || 'hover'; this.state.dockDisplayMode = mode; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('扩展坞显示模式', e); } if (typeof applyDockDisplayMode === 'function') { applyDockDisplayMode(mode); } }; $('#sp_dockDisplayMode').off('change').on('change', applyDockDisplayModeImmediately); $('#sp_enableImageHideMode').off('change').on('change', applyImageHideModeImmediately); $('#sp_applyImageHideMode').off('change').on('change', applyImageHideModeImmediately); // 阅图模式分隔线:即时切换并即时应用(无需点“应用更改”) const applyImageViewerSeparatorsImmediately = () => { const on = $('#sp_imageViewerSeparators').is(':checked'); this.state.imageViewerSeparators = on; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('阅图分隔线', e); } if (typeof applyImageViewerSeparatorSetting === 'function') { applyImageViewerSeparatorSetting(on); } toast(on ? '已开启分隔线' : '已关闭分隔线', 900, { queue: false, key: 'image-viewer-separators' }); }; $('#sp_imageViewerSeparators').off('change').on('change', applyImageViewerSeparatorsImmediately); // 设置面板内打开阅图:串内页可用;无图模式下右上角按钮隐藏时的备选入口 $('#sp_openImageViewer').off('click').on('click', (e) => { e.preventDefault(); e.stopPropagation(); if (typeof openImageViewerFromSettingsPanel === 'function') { openImageViewerFromSettingsPanel(); } else if (typeof toast === 'function') { toast('当前页面不支持阅图模式'); } }); // 设置面板内打开导出窗口:串页可用(与「导」按钮同一入口) $('#sp_openThreadExport').off('click').on('click', (e) => { e.preventDefault(); e.stopPropagation(); try { if (typeof xdexExportRuntimeAvailable === 'function' && !xdexExportRuntimeAvailable()) { toast('导出需安装扩展版 X岛-EX,或脚本猫后台导出脚本'); return; } if (typeof xdexExportOpenDialog === 'function') { xdexExportOpenDialog(''); } else { toast('导出功能尚未就绪,请稍后重试'); } } catch (err) { console.warn('[X岛-EX] 打开导出窗口失败', err); toast('打开导出窗口失败'); } }); // 屏蔽显示模式:即时切换并即时生效(折叠/隐藏) const applyBlockDisplayModeImmediately = () => { const mode = $('#sp_blockDisplayMode').val() || 'fold'; this.state.blockDisplayMode = mode; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('屏蔽显示模式', e); } refreshFilterDisplay(this.state); }; $('#sp_blockDisplayMode').off('change').on('change', applyBlockDisplayModeImmediately); const applyThreadCookieWhitelistDisplayModeImmediately = () => { this.state.threadCookieWhitelistDisplayMode = $('#sp_threadCookieWhitelistDisplayMode').val() || 'fold'; this.state.poAnnotationSideDisplayMode = $('#sp_poAnnotationSideDisplayMode').val() || 'collapse'; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('串内饼干白名单显示模式', e); } refreshFilterDisplay(this.state); }; $('#sp_threadCookieWhitelistDisplayMode').off('change').on('change', applyThreadCookieWhitelistDisplayModeImmediately); $('#sp_poAnnotationSideDisplayMode').off('change').on('change', applyThreadCookieWhitelistDisplayModeImmediately); // 颜文字排序:即时切换并即时生效(无需点“应用更改”) const applyKaomojiSortImmediately = () => { const mode = $('#sp_kaomojiSort').val() || 'default'; this.state.kaomojiSort = mode; try { GM_setValue(this.key, this.state); } catch (e) { warnSettingSaveFailed('颜文字排序', e); } // 与颜文字按钮右侧的快捷下拉实时同步 $('.sp_kaomojiSort_copy').val(mode); document.querySelectorAll('#h-emot-select').forEach(sel => { try { sel.dispatchEvent(new Event('kaomoji:sort-changed')); } catch (e) {} }); // 广播排序模式变化,供其它复制下拉同步 try { window.dispatchEvent(new CustomEvent('kaomoji:sort-mode-changed', { detail: { mode, source: 'settings' } })); } catch (e) {} }; $('#sp_kaomojiSort').off('change').on('change', applyKaomojiSortImmediately); (function initPostExpandModeSelect() { const sel = document.getElementById('sp_postExpandAllMode'); if (!sel) return; sel.value = (SettingPanel.state && SettingPanel.state.enablePostExpandAll) ? 'expand' : 'collapse'; sel.addEventListener('change', (e) => { e.stopPropagation(); const nextState = (sel.value || 'expand') === 'expand'; SettingPanel.state.enablePostExpandAll = nextState; const items = document.querySelectorAll('.h-threads-item-index'); let anchor = null; for (const item of items) { const rect = item.getBoundingClientRect(); if (rect.top >= 0) { anchor = item; break; } } if (!anchor && items.length) anchor = items[items.length - 1]; const anchorTopBefore = anchor ? anchor.getBoundingClientRect().top : null; items.forEach(item => { const toggleBtn = item.querySelector('.h-threads-info .js-toggle-mode'); if (!toggleBtn) return; const expanded = item.classList.contains('expanded'); if (nextState && !expanded) { toggleBtn.click(); } else if (!nextState && expanded) { toggleBtn.click(); } }); if (!nextState && anchor && anchorTopBefore !== null) { requestAnimationFrame(() => { const anchorTopAfter = anchor.getBoundingClientRect().top; const delta = anchorTopAfter - anchorTopBefore; window.scrollBy({ top: delta, behavior: 'instant' }); }); } try { GM_setValue(SettingPanel.key, SettingPanel.state); } catch (err) {} toast(nextState ? '已展开长串' : '已折叠长串'); }); })(); (function initPostAfterActionSelect() { const sel = document.getElementById('sp_postAfterAction'); if (!sel) return; sel.value = (SettingPanel.state && SettingPanel.state.postAfterAction) || 'jump'; sel.addEventListener('change', (e) => { e.stopPropagation(); const action = (sel.value || 'jump') === 'jump' ? 'jump' : 'refresh'; SettingPanel.state.postAfterAction = action; try { GM_setValue(SettingPanel.key, SettingPanel.state); } catch (err) {} toast(action === 'jump' ? '已切换为:发串后新标签页打开' : '已切换为:发串后刷新页面回顶部'); }); })(); (function initDraftModeSelect() { const sel = document.getElementById('sp_enableDraftMode'); if (!sel) return; sel.value = (SettingPanel.state && SettingPanel.state.enableDraft) ? 'off' : 'on'; sel.addEventListener('change', (e) => { e.stopPropagation(); const enabled = (sel.value || 'off') === 'off'; SettingPanel.state.enableDraft = enabled; try { GM_setValue(SettingPanel.key, SettingPanel.state); } catch (err) {} if (!enabled) { deleteAllDraftsSafe(); toast('已清除缓存草稿并关闭草稿功能'); } else { try { migrateLegacyDraftIfNeeded(); } catch (err) {} try { if (typeof 载入编辑 === 'function') 载入编辑(); } catch (err) {} try { if (typeof 注册自动保存编辑 === 'function') 注册自动保存编辑(); } catch (err) {} toast('已开启草稿缓存'); } }); })(); (function initTimeDisplayModeSelect() { const sel = document.getElementById('sp_timeDisplayMode'); if (!sel) return; sel.value = (SettingPanel.state && SettingPanel.state.timeDisplayMode === 'exact') ? 'exact' : 'relative'; sel.addEventListener('change', (e) => { e.stopPropagation(); const mode = (sel.value === 'exact') ? 'exact' : 'relative'; SettingPanel.state.timeDisplayMode = mode; try { GM_setValue(SettingPanel.key, SettingPanel.state); } catch (err) {} try { if (typeof window.__xdexApplyTimeDisplayMode === 'function') window.__xdexApplyTimeDisplayMode(document); } catch (err) {} toast(mode === 'exact' ? '已切换为精确时间' : '已切换为相对时间'); }); })(); // 标记:新增组输入 $('#btn_group_marked').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#marked-inputs-container .marked-row').length + 1; $('#marked-inputs-container').append( spUi('buildCookieGroupTwoFieldRowHtml', 'marked', nextIndex) ).find('.marked-desc-input').last().focus(); }); // 屏蔽:新增组输入 $('#btn_group_blocked').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#blocked-inputs-container .blocked-row').length + 1; $('#blocked-inputs-container').append( spUi('buildCookieGroupTwoFieldRowHtml', 'blocked', nextIndex) ).find('.blocked-desc-input').last().focus(); }); $('#btn_group_threadCookieWhitelist').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#thread-cookie-whitelist-inputs-container .thread-cookie-whitelist-row').length + 1; $('#thread-cookie-whitelist-inputs-container').append(spUi('buildThreadCookieWhitelistRowHtml', nextIndex)); $('#thread-cookie-whitelist-inputs-container .thread-cookie-whitelist-row').last().find('.thread-cookie-whitelist-desc-input').focus(); }); $('#btn_group_blockedKeywords').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#blocked-keyword-inputs-container .blocked-keyword-row').length + 1; $('#blocked-keyword-inputs-container').append(spUi('buildBlockedKeywordGroupRowHtml', nextIndex)); $('#blocked-keyword-inputs-container .blocked-keyword-row').last().find('.blocked-keyword-input').focus(); }); $('#btn_group_favoriteThreads').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#favorite-thread-inputs-container .favorite-thread-row').length + 1; $('#favorite-thread-inputs-container').append(spUi('buildFavoriteThreadRowHtml', nextIndex)); $('#favorite-thread-inputs-container .favorite-thread-row').last().find('.favorite-thread-desc-input').focus(); }); $('#btn_group_subscriptionFeeds').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#subscription-feed-inputs-container .subscription-feed-row').length + 1; $('#subscription-feed-inputs-container').append(spUi('buildSubscriptionFeedRowHtml', nextIndex)); $('#subscription-feed-inputs-container .subscription-feed-row').last().find('.subscription-feed-desc-input').focus(); }); // 「我的订阅」旁的齿轮:滚动到订阅号折叠区并展开(默认订阅号在行内设置) $('#sp_openDefaultSubscriptionFeed').off('click').on('click', (e) => { e.preventDefault(); e.stopPropagation(); const $fold = $('#subscription-feed-inputs-container').closest('.sp_fold'); if (!$fold.length) return; const $body = $fold.find('.sp_fold_body'); if (!$body.is(':visible')) $fold.find('.sp_fold_head').first().trigger('click'); setTimeout(() => { try { $fold[0].scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (err) { try { $fold[0].scrollIntoView(); } catch (e2) {} } }, 80); }); const saveMarkedGroups = ({ fromDelete = false } = {}) => { const parsed = collectMarkedGroupsFromPanel(); if (!parsed) return false; this.state.markedGroups = parsed; GM_setValue(this.key, this.state); this.syncInputs(); toast(fromDelete ? '已删除标记分组' : '标记分组已保存'); refreshFilterDisplay(this.state); return true; }; const saveBlockedGroups = ({ fromDelete = false } = {}) => { const parsed = []; let valid = true; $('#blocked-inputs-container .blocked-row').each((idx, el)=>{ const $row = $(el); const desc = ($row.find('.blocked-desc-input').val() || '').trim(); const cookies = Utils.strToList(($row.find('.blocked-cookies-input').val() || '').trim()); if (!desc && !cookies.length) return; if (!spData('isValidDesc', desc)) { toast(`第${idx + 1}条备注过长`); valid=false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid=false; return false; } if (cookies.some(id=>!Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid=false; return false; } parsed.push({ desc, cookies }); }); if (!valid) return false; this.state.blockedCookies = parsed; GM_setValue(this.key, this.state); this.syncInputs(); toast(fromDelete ? '已删除屏蔽分组' : '屏蔽分组已保存'); refreshFilterDisplay(this.state); return true; }; const saveThreadCookieWhitelistGroups = ({ fromDelete = false } = {}) => { const parsed = []; let valid = true; $('#thread-cookie-whitelist-inputs-container .thread-cookie-whitelist-row').each((idx, el) => { const $row = $(el); const desc = ($row.find('.thread-cookie-whitelist-desc-input').val() || '').trim(); const threads = Utils.strToList(($row.find('.thread-cookie-whitelist-threads-input').val() || '').trim()); const cookies = Utils.strToList(($row.find('.thread-cookie-whitelist-cookies-input').val() || '').trim()); if (!desc && !threads.length && !cookies.length) return; if (!spData('isValidDesc', desc)) { toast(`第${idx + 1}条备注过长`); valid = false; return false; } if (!threads.length) { toast(`第${idx + 1}条未指定串号`); valid = false; return false; } if (threads.some(id => !spData('isValidThreadId', id))) { toast(`第${idx + 1}条存在不合法串号`); valid = false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid = false; return false; } if (cookies.some(id => !Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid = false; return false; } parsed.push({ desc, threads, cookies, rowIndex: idx + 1 }); }); if (!valid) return false; const { groups, mergeEvents } = spData('mergeThreadCookieWhitelistGroups', parsed); this.state.threadCookieWhitelistGroups = groups; GM_setValue(this.key, this.state); mergeEvents.forEach((event) => { if (event.desc) { toast(`第${event.rowIndex}条(${event.desc})中的串号 ${event.threadId} 已与现有只看规则合并`); } else { toast(`第${event.rowIndex}条的串号 ${event.threadId}(${event.cookies.join(',')})已与现有只看规则合并`); } }); this.syncInputs(); toast(fromDelete ? '已删除只看饼干分组' : '只看饼干分组已保存'); refreshFilterDisplay(this.state); return true; }; const collectBlockedKeywordGroupsFromPanel = () => { const parsed = []; let valid = true; $('#blocked-keyword-inputs-container .blocked-keyword-row').each((idx, el)=>{ const $row = $(el); const rawValue = ($row.find('.blocked-keyword-input').val() || '').trim(); const regex = !!$row.find('.blocked-keyword-regex').prop('checked'); if (!rawValue) return; if (regex) { if (!spData('compileBlockedKeywordRegex', rawValue)) { toast(`第${idx + 1}组正则无效,请检查表达式`); valid = false; return false; } parsed.push({ value: rawValue, regex: true }); return; } if (Utils.strToList(rawValue).length) parsed.push({ value: rawValue, regex: false }); }); return valid ? parsed : null; }; const saveBlockedKeywordGroups = ({ fromDelete = false, fromToggle = false, toggleIndex = 0, toggleOn = false } = {}) => { const parsed = collectBlockedKeywordGroupsFromPanel(); if (!parsed) return false; this.state.blockedKeywords = parsed; GM_setValue(this.key, this.state); // 开关切换实时保存时避免 syncInputs 整表重绘打断当前行焦点 if (!fromToggle) this.syncInputs(); if (fromDelete) toast('已删除关键词分组'); else if (fromToggle) toast(`第${toggleIndex}组${toggleOn ? '开启' : '关闭'}正则`); else toast('屏蔽关键词已保存'); refreshFilterDisplay(this.state); return true; }; const saveFavoriteThreads = ({ fromDelete = false } = {}) => { const parsed = spUi('collectFavoriteThreadsFromPanel'); if (!parsed) return false; this.state.favoriteThreads = parsed; GM_setValue(this.key, this.state); this.syncInputs(); renderFavoriteThreadsMenu(); toast(fromDelete ? '已删除常用串' : '常用串已保存'); return true; }; $('#marked-inputs-container').off('click', '.marked-delete').on('click', '.marked-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.marked-row').remove(); saveMarkedGroups({ fromDelete: true }); return false; }); $('#blocked-inputs-container').off('click', '.blocked-delete').on('click', '.blocked-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.blocked-row').remove(); saveBlockedGroups({ fromDelete: true }); return false; }); $('#thread-cookie-whitelist-inputs-container').off('click', '.thread-cookie-whitelist-delete').on('click', '.thread-cookie-whitelist-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.thread-cookie-whitelist-row').remove(); saveThreadCookieWhitelistGroups({ fromDelete: true }); return false; }); $('#blocked-keyword-inputs-container').off('click', '.blocked-keyword-delete').on('click', '.blocked-keyword-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.blocked-keyword-row').remove(); saveBlockedKeywordGroups({ fromDelete: true }); return false; }); // 正则开关:切换 placeholder,并实时保存 + 刷新过滤(无需点保存) $('#blocked-keyword-inputs-container').off('change.blockedKeywordRegex', '.blocked-keyword-regex').on('change.blockedKeywordRegex', '.blocked-keyword-regex', function () { const $sw = $(this); const $row = $sw.closest('.blocked-keyword-row'); const on = !!$sw.prop('checked'); const toggleIndex = $('#blocked-keyword-inputs-container .blocked-keyword-row').index($row) + 1; $row.attr('data-regex', on ? '1' : '0'); $row.find('.blocked-keyword-input').attr('placeholder', spUi('getBlockedKeywordPlaceholder', on)); // 实时写入配置并 applyFilters;失败(如正则非法)则回滚开关 if (!saveBlockedKeywordGroups({ fromToggle: true, toggleIndex, toggleOn: on })) { $sw.prop('checked', !on); $row.attr('data-regex', !on ? '1' : '0'); $row.find('.blocked-keyword-input').attr('placeholder', spUi('getBlockedKeywordPlaceholder', !on)); } }); $('#favorite-thread-inputs-container').off('click', '.favorite-thread-delete').on('click', '.favorite-thread-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.favorite-thread-row').remove(); saveFavoriteThreads({ fromDelete: true }); return false; }); // 标记:保存 $('#btn_sp_marked').off('click').on('click', e=>{ e.stopPropagation(); saveMarkedGroups(); }); // 屏蔽:保存 $('#btn_sp_blocked').off('click').on('click', e=>{ e.stopPropagation(); saveBlockedGroups(); }); $('#btn_sp_blockedKeywords').off('click').on('click', e=>{ e.stopPropagation(); saveBlockedKeywordGroups(); }); $('#btn_sp_favoriteThreads').off('click').on('click', e=>{ e.stopPropagation(); saveFavoriteThreads(); }); $('#subscription-feed-inputs-container').off('click', '.subscription-feed-delete').on('click', '.subscription-feed-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.subscription-feed-row').remove(); saveSubscriptionFeeds({ fromDelete: true }); return false; }); // 订阅号:保存 const saveSubscriptionFeeds = ({ fromDelete = false } = {}) => { const parsed = spUi('collectSubscriptionFeedsFromPanel'); if (!parsed) return false; this.state.subscriptionFeeds = parsed; // 默认订阅号指向的 uuid 已被删除/修改 → 清空默认(打时间戳,供跨端“新者胜”) const defUuid = String(this.state.defaultSubscriptionFeedUuid || '').trim(); if (defUuid && !parsed.some((item) => String(item.uuid || '').trim() === defUuid)) { this.state.defaultSubscriptionFeedUuid = ''; this.state.defaultSubscriptionFeedUpdatedAt = Date.now(); } GM_setValue(this.key, this.state); this.syncInputs(); toast(fromDelete ? '已删除订阅号' : '订阅号已保存'); return true; }; $('#btn_sp_subscriptionFeeds').off('click').on('click', e=>{ e.stopPropagation(); saveSubscriptionFeeds(); }); // 默认订阅号开关(每行一个,互斥;最多存在一个默认) $('#subscription-feed-inputs-container').off('change.subscriptionDefault', '.subscription-feed-default') .on('change.subscriptionDefault', '.subscription-feed-default', (e) => { const $sw = $(e.currentTarget); const $row = $sw.closest('.subscription-feed-row'); const uuid = String($row.find('.subscription-feed-uuid-input').val() || '').trim(); if ($sw.is(':checked')) { if (!uuid) { $sw.prop('checked', false); toast('请先填写订阅号'); return; } const savedUuids = ((typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []) .map((item) => String(item.uuid || '').trim()); if (!savedUuids.includes(uuid)) { $sw.prop('checked', false); toast('请先保存该订阅号,再设为默认'); return; } setDefaultSubscriptionFeedUuid(uuid); const feed = (((typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds) || []) .find((item) => String(item.uuid || '').trim() === uuid); toast(`已将默认订阅号设为「${(feed && feed.desc) || uuid}」`); } else { if (getDefaultSubscriptionFeedUuid() === uuid) { setDefaultSubscriptionFeedUuid(''); toast('已取消默认订阅号'); } } }); $('#btn_sp_threadCookieWhitelist').off('click').on('click', e=>{ e.stopPropagation(); saveThreadCookieWhitelistGroups(); }); // ===== 分组行拖拽排序注册(拖动结束即保存 + 写入 groupOrderMeta 供跨端传播;失败自动回滚) ===== installRowDragSorting({ containerSelector: '#marked-inputs-container', rowSelector: '.marked-row', field: 'markedGroups', onSorted: () => saveMarkedGroups() }); installRowDragSorting({ containerSelector: '#blocked-inputs-container', rowSelector: '.blocked-row', field: 'blockedCookies', onSorted: () => saveBlockedGroups() }); installRowDragSorting({ containerSelector: '#thread-cookie-whitelist-inputs-container', rowSelector: '.thread-cookie-whitelist-row', field: 'threadCookieWhitelistGroups', onSorted: () => saveThreadCookieWhitelistGroups() }); installRowDragSorting({ containerSelector: '#blocked-keyword-inputs-container', rowSelector: '.blocked-keyword-row', field: 'blockedKeywords', onSorted: () => saveBlockedKeywordGroups() }); installRowDragSorting({ containerSelector: '#favorite-thread-inputs-container', rowSelector: '.favorite-thread-row', field: 'favoriteThreads', onSorted: () => saveFavoriteThreads() }); installRowDragSorting({ containerSelector: '#subscription-feed-inputs-container', rowSelector: '.subscription-feed-row', field: 'subscriptionFeeds', onSorted: () => saveSubscriptionFeeds() }); const closeMarkedColorPopovers = () => { $('#marked-inputs-container .marked-color-popover').hide(); }; const getMarkedRowDefaultColor = ($row) => { const rowIndex = Math.max($row.index(), 0); return XDex.data.markColors[rowIndex % XDex.data.markColors.length]; }; const updateMarkedRowSwatch = ($row, draftColor) => { const color = spData('normalizeHexColor', draftColor) || getMarkedRowDefaultColor($row); $row.find('.marked-color-swatch').css('background', color).attr('data-default-color', color); $row.find('.marked-color-preview').css('background', color); }; const readMarkedColorInputValue = ($popover) => ($popover.find('.marked-color-input').val() || '').trim(); const readMarkedColorInputAsHex = ($popover) => { const state = $popover.data('pickerState') || {}; const rawValue = readMarkedColorInputValue($popover); if (!rawValue) return ''; if ((state.format || 'hex') === 'rgb') { const rgb = spData('parseRgbColorString', rawValue); return rgb ? spData('rgbToHex', rgb) : null; } const normalized = spData('normalizeHexColor', rawValue); return normalized || null; }; const setMarkedColorInputFromState = ($popover) => { const state = $popover.data('pickerState'); if (!state) return; const inputValue = state.inputEmpty ? '' : (state.format === 'rgb' ? spData('formatRgbColor', spData('hexToRgb', state.hex)) : state.hex); $popover.find('.marked-color-input').val(inputValue); }; const updateMarkedColorFormatButtons = ($popover) => { const state = $popover.data('pickerState') || {}; $popover.find('.marked-color-format').each((_, el) => { const $btn = $(el); const active = $btn.data('format') === (state.format || 'hex'); $btn.css({ border: active ? '1px solid #7da6bf' : '1px solid #a98f7a', background: active ? '#66CCFF' : '#F0E0D6', color: active ? '#fff' : '#6f5d50', }); }); }; const renderMarkedColorPicker = ($row) => { const $popover = $row.find('.marked-color-popover'); const state = $popover.data('pickerState'); if (!state) return; const displayHex = spData('normalizeHexColor', state.hex) || state.defaultHex; const displayHsv = spData('hexToHsv', displayHex); const hueHex = spData('hsvToHex', state.hsv.h, 1, 1); const $sv = $popover.find('.marked-color-sv'); const $svThumb = $popover.find('.marked-color-sv-thumb'); const $hueThumb = $popover.find('.marked-color-hue-thumb'); $sv.css('background', `linear-gradient(to top, #000 0%, transparent 100%), linear-gradient(to right, #fff 0%, ${hueHex} 100%)`); $svThumb.css({ left: `${state.hsv.s * 100}%`, top: `${(1 - state.hsv.v) * 100}%` }); $hueThumb.css('left', `${(state.hsv.h / 360) * 100}%`); $popover.find('.marked-color-preview').css('background', displayHex); $popover.find('.marked-color-status').text(state.inputEmpty ? `默认色 ${state.defaultHex}` : displayHex); $popover.find('.marked-color-default-hint').text(`默认 ${state.defaultHex}`); setMarkedColorInputFromState($popover); updateMarkedColorFormatButtons($popover); updateMarkedRowSwatch($row, state.inputEmpty ? '' : displayHex); }; const setMarkedColorPickerHex = ($row, nextHex, options = {}) => { const $popover = $row.find('.marked-color-popover'); const state = $popover.data('pickerState'); if (!state) return; const normalized = spData('normalizeHexColor', nextHex); const allowEmpty = !!options.allowEmpty; if (!normalized && !allowEmpty) return; state.inputEmpty = !normalized; state.hex = normalized || state.defaultHex; state.hsv = spData('hexToHsv', state.hex); $popover.data('pickerState', state); renderMarkedColorPicker($row); }; const setMarkedColorPickerFormat = ($row, format) => { const $popover = $row.find('.marked-color-popover'); const state = $popover.data('pickerState'); if (!state) return; state.format = format === 'rgb' ? 'rgb' : 'hex'; $popover.data('pickerState', state); renderMarkedColorPicker($row); }; const updateMarkedColorPickerFromPointer = ($row, areaType, clientX, clientY) => { const $popover = $row.find('.marked-color-popover'); const state = $popover.data('pickerState'); if (!state) return; if (areaType === 'sv') { const rect = $popover.find('.marked-color-sv')[0].getBoundingClientRect(); const s = spData('clampColorChannel', (clientX - rect.left) / rect.width, 0, 1); const v = spData('clampColorChannel', 1 - ((clientY - rect.top) / rect.height), 0, 1); state.hsv.s = s; state.hsv.v = v; } else { const rect = $popover.find('.marked-color-hue')[0].getBoundingClientRect(); state.hsv.h = spData('clampColorChannel', ((clientX - rect.left) / rect.width) * 360, 0, 360); if (state.hsv.h === 360) state.hsv.h = 359.999; } state.inputEmpty = false; state.hex = spData('hsvToHex', state.hsv.h, state.hsv.s, state.hsv.v); $popover.data('pickerState', state); renderMarkedColorPicker($row); }; const positionMarkedColorPopover = ($row) => { const $cell = $row.find('.marked-color-cell'); const $popover = $row.find('.marked-color-popover'); if (!$cell.length || !$popover.length) return; $popover.css({ top: '', bottom: '' }); const cellRect = $cell[0].getBoundingClientRect(); const popRect = $popover[0].getBoundingClientRect(); const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; const gap = 6; const spaceBelow = viewportHeight - cellRect.bottom - gap; const spaceAbove = cellRect.top - gap; const openAbove = spaceBelow < popRect.height && spaceAbove > spaceBelow; const popoverHeight = popRect.height; if (openAbove) { let offsetBottom = cellRect.height + gap; const topIfOpened = cellRect.bottom - offsetBottom - popoverHeight; if (topIfOpened < 4) { offsetBottom = Math.max(cellRect.bottom - popoverHeight - 4, gap); } $popover.css({ top: 'auto', bottom: `${offsetBottom}px` }); } else { let offsetTop = cellRect.height + gap; const overflow = cellRect.top + offsetTop + popoverHeight - viewportHeight + 4; if (overflow > 0) { offsetTop = Math.max(offsetTop - overflow, -cellRect.top + 4); } $popover.css({ top: `${offsetTop}px`, bottom: 'auto' }); } }; const openMarkedColorPopover = ($row) => { const $popover = $row.find('.marked-color-popover'); const storedColor = ($row.find('.marked-color-value').val() || '').trim(); const defaultHex = getMarkedRowDefaultColor($row); const displayHex = spData('normalizeHexColor', storedColor) || defaultHex; $popover.data('pickerState', { format: 'hex', defaultHex, inputEmpty: !spData('normalizeHexColor', storedColor), hex: displayHex, hsv: spData('hexToHsv', displayHex), }); renderMarkedColorPicker($row); $popover.show(); positionMarkedColorPopover($row); $popover.find('.marked-color-input').trigger('focus').trigger('select'); }; const beginMarkedColorDrag = ($row, areaType, startEvent) => { const moveEvent = startEvent.type.indexOf('touch') === 0 ? 'touchmove.markedColorDrag' : 'mousemove.markedColorDrag'; const endEvent = startEvent.type.indexOf('touch') === 0 ? 'touchend.markedColorDrag touchcancel.markedColorDrag' : 'mouseup.markedColorDrag'; const getPoint = (evt) => { const touch = evt.originalEvent && evt.originalEvent.touches && evt.originalEvent.touches[0]; const changedTouch = evt.originalEvent && evt.originalEvent.changedTouches && evt.originalEvent.changedTouches[0]; return touch || changedTouch || evt; }; const applyPointer = (evt) => { const point = getPoint(evt); updateMarkedColorPickerFromPointer($row, areaType, point.clientX, point.clientY); }; applyPointer(startEvent); $(document).off('.markedColorDrag').on(moveEvent, (evt) => { evt.preventDefault(); applyPointer(evt); }).on(endEvent, () => { $(document).off('.markedColorDrag'); }); }; const collectMarkedGroupsFromPanel = () => { const parsed = []; let valid = true; // 与已保存分组按备注比对,仅在配色实际变化时刷新 colorAt(拖拽排序/改饼干不误触发) const prevGroups = (typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state && Array.isArray(SettingPanel.state.markedGroups)) ? SettingPanel.state.markedGroups : []; $('#marked-inputs-container .marked-row').each((idx, el)=>{ const $row = $(el); const desc = ($row.find('.marked-desc-input').val() || '').trim(); const cookies = Utils.strToList(($row.find('.marked-cookies-input').val() || '').trim()); const rawColor = ($row.find('.marked-color-value').val() || '').trim(); if (!desc && !cookies.length) return; if (!spData('isValidDesc', desc)) { toast(`第${idx + 1}条备注过长`); valid=false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid=false; return false; } if (cookies.some(id=>!Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid=false; return false; } if (rawColor && !spData('isValidHexColor', rawColor)) { toast(`第${idx + 1}条颜色格式无效,应为 #RRGGBB`); valid=false; return false; } const color = spData('normalizeHexColor', rawColor); const prev = prevGroups.find((g) => String((g && g.desc) || '') === desc); const prevColor = prev ? spData('normalizeHexColor', prev.color) : ''; const prevAt = prev ? Math.max(0, Number(prev.colorAt) || 0) : 0; // 配色未变 → 保留旧时间戳;变化(含清除)→ 打当前时间 const colorAt = color === prevColor ? prevAt : Date.now(); parsed.push({ desc, color, colorAt, cookies }); }); return valid ? parsed : null; }; $(document).off('click.markedColorPopover').on('click.markedColorPopover', (e) => { if ($(e.target).closest('#marked-inputs-container .marked-color-cell').length) return; closeMarkedColorPopovers(); }); $('#marked-inputs-container').off('click', '.marked-color-swatch').on('click', '.marked-color-swatch', (e) => { e.preventDefault(); e.stopPropagation(); const $row = $(e.currentTarget).closest('.marked-row'); const $popover = $row.find('.marked-color-popover'); const shouldOpen = !$popover.is(':visible'); closeMarkedColorPopovers(); if (!shouldOpen) return false; openMarkedColorPopover($row); return false; }); $('#marked-inputs-container').off('input', '.marked-color-input').on('input', '.marked-color-input', (e) => { const $row = $(e.currentTarget).closest('.marked-row'); const $popover = $row.find('.marked-color-popover'); const normalized = readMarkedColorInputAsHex($popover); if (normalized === '') { setMarkedColorPickerHex($row, '', { allowEmpty: true }); return; } if (normalized) setMarkedColorPickerHex($row, normalized); }); $('#marked-inputs-container').off('blur', '.marked-color-input').on('blur', '.marked-color-input', (e) => { const $row = $(e.currentTarget).closest('.marked-row'); const $popover = $row.find('.marked-color-popover'); const parsedHex = readMarkedColorInputAsHex($popover); if (parsedHex === '') { setMarkedColorPickerHex($row, '', { allowEmpty: true }); return; } if (parsedHex) { setMarkedColorPickerHex($row, parsedHex); return; } renderMarkedColorPicker($row); }); $('#marked-inputs-container').off('click', '.marked-color-format').on('click', '.marked-color-format', (e) => { e.preventDefault(); e.stopPropagation(); const $row = $(e.currentTarget).closest('.marked-row'); setMarkedColorPickerFormat($row, $(e.currentTarget).data('format')); return false; }); $('#marked-inputs-container').off('mousedown touchstart', '.marked-color-sv').on('mousedown touchstart', '.marked-color-sv', (e) => { e.preventDefault(); e.stopPropagation(); beginMarkedColorDrag($(e.currentTarget).closest('.marked-row'), 'sv', e); return false; }); $('#marked-inputs-container').off('mousedown touchstart', '.marked-color-hue').on('mousedown touchstart', '.marked-color-hue', (e) => { e.preventDefault(); e.stopPropagation(); beginMarkedColorDrag($(e.currentTarget).closest('.marked-row'), 'hue', e); return false; }); $('#marked-inputs-container').off('click', '.marked-color-clear').on('click', '.marked-color-clear', (e) => { e.preventDefault(); e.stopPropagation(); const $row = $(e.currentTarget).closest('.marked-row'); const $popover = $row.find('.marked-color-popover'); $row.find('.marked-color-value').val(''); if ($popover.is(':visible')) { setMarkedColorPickerHex($row, '', { allowEmpty: true }); const state = $popover.data('pickerState'); if (state) { state.format = 'hex'; $popover.data('pickerState', state); renderMarkedColorPicker($row); } } else { updateMarkedRowSwatch($row, ''); } $popover.hide(); return false; }); $('#marked-inputs-container').off('click', '.marked-color-save').on('click', '.marked-color-save', (e) => { e.preventDefault(); e.stopPropagation(); const $row = $(e.currentTarget).closest('.marked-row'); const $popover = $row.find('.marked-color-popover'); const parsedHex = readMarkedColorInputAsHex($popover); if (parsedHex === null) { toast('颜色格式无效,应为 #RRGGBB 或 rgb(r, g, b)'); $row.find('.marked-color-input').trigger('focus'); return false; } const normalized = spData('normalizeHexColor', parsedHex); $row.find('.marked-color-value').val(normalized); if (normalized) { setMarkedColorPickerHex($row, normalized); setMarkedColorPickerFormat($row, 'hex'); } else { setMarkedColorPickerHex($row, '', { allowEmpty: true }); } updateMarkedRowSwatch($row, normalized); $popover.hide(); // 直接复用「保存」(sp_save / #btn_sp_marked) 的那条路径:收集 → 持久化 → syncInputs // → refreshFilterDisplay(即时生效,无需刷新)。 // 此前这里只改了 .marked-color-value 隐藏字段就关闭弹窗,颜色不会落盘。 saveMarkedGroups(); return false; }); // ========== 导入/导出配置 ========== function parseJSONC(str) { // 逐字符解析:只在字符串外部去掉 // 注释和尾随逗号(不清理零宽字符,保留用户数据完整性) let out = '', inStr = false, esc = false; for (let i = 0; i < str.length; i++) { const ch = str[i]; if (inStr) { out += ch; if (esc) { esc = false; } else if (ch === '\\') { esc = true; } else if (ch === '"') { inStr = false; } } else { if (ch === '"') { inStr = true; out += ch; } else if (ch === '/' && str[i + 1] === '/') { // 跳过行注释 while (i < str.length && str[i] !== '\n') i++; out += '\n'; } else if (ch === ',' && /^\s*[}\]]/.test(str.slice(i + 1))) { // 尾随逗号:后面紧跟 } 或 ](允许空白),跳过逗号 continue; } else { out += ch; } } } return JSON.parse(out); } // 固定开启项:UI 中为 disabled + checked,无需导入/导出 const FIXED_KEYS = new Set([ 'enableImageHideMode', // 固定启用(applyImageHideMode 仍可导出) 'interceptReplyForm', // 拦截回复中间页 'updateReplyNumbers', // 当页回复编号 'replaceRightSidebar', // 扩展坞增强 'kaomojiEnhancer', // 颜文字拓展(kaomojiSort 仍可导出) 'highlightPO', // 标记Po主 'enhancePostFormLayout', // 发串UI调整 'applyFilters', // 标记/屏蔽-饼干/关键词 'enhanceIsland', // 增强X岛匿名版 'enablePostExpand', // 展开板块页长串 'searchServiceBy4sY', // 野生搜索酱 ]); // 清理字符串值中的零宽/不可见控制字符 function sanitizeValue(val) { if (typeof val === 'string') { return val.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069]/g, ''); } if (Array.isArray(val)) return val.map(sanitizeValue); if (val && typeof val === 'object') { const o = {}; for (const [k2, v2] of Object.entries(val)) o[k2] = sanitizeValue(v2); return o; } return val; } // === 使用数据导入导出 === const FULL_EXPORT_SCHEMA_VERSION = 1; const FULL_IMPORT_DIRECT_OVERRIDE_KEYS = [ 'enableCookieSwitch', 'enableCookieConfirm', 'disableWatermark', 'enablePaginationDuplication', 'updatePreviewCookie', 'hideEmptyTitleEmail', 'enableExternalImagePreview', 'enableAutoCookieRefresh', 'enableAutoCookieRefreshToast', 'interceptReplyFormUnvcode', 'interceptReplyFormU200B', 'interceptReplyFormAutoCompress', 'enableSeamlessPaging', 'enableAutoSeamlessPaging', 'enableHDImageAndLayoutFix', 'enableLinkBlank', 'enableAutoUrlLinkify', 'enableQuotePreview', 'enableUpdateCheck', 'enableImageContextMenu', 'enableImageHideMode', 'applyImageHideMode', 'imageViewerSeparators', 'enableDraft', 'timeDisplayMode', 'extendQuote', 'kaomojiSort', 'toggleSidebar', 'threadCookieWhitelistDisplayMode', 'poAnnotationSideDisplayMode', 'replyModeDefault', 'replyExtraDefault', 'blockDisplayMode', 'postAfterAction', 'enablePostExpandAll', 'dockDisplayMode', 'disableAutoQuote', 'enableCustomDarkTheme' ]; function mergeFavoriteThreads(localItems, importedItems) { const local = Array.isArray(localItems) ? spData('normalizeFavoriteThreads', localItems) : []; const imported = Array.isArray(importedItems) ? spData('normalizeFavoriteThreads', importedItems) : []; const seen = new Map(); local.forEach((item) => { const key = String(item.threadId || '').trim(); if (key) seen.set(key, item); }); imported.forEach((item) => { const key = String(item.threadId || '').trim(); if (!key) return; if (!seen.has(key)) { seen.set(key, item); } else { const existing = seen.get(key); if (!existing.desc && item.desc) existing.desc = item.desc; } }); return Array.from(seen.values()); } function mergeSubscriptionFeeds(localItems, importedItems) { const local = Array.isArray(localItems) ? localItems : []; const imported = Array.isArray(importedItems) ? importedItems : []; const seen = new Map(); local.forEach((item) => { const key = String(item.uuid || '').trim(); if (key) seen.set(key, item); }); imported.forEach((item) => { const key = String(item.uuid || '').trim(); if (!key) return; if (!seen.has(key)) { seen.set(key, item); } else { const existing = seen.get(key); if (!existing.desc && item.desc) existing.desc = item.desc; } }); return Array.from(seen.values()); } function mergeMarkedGroups(localGroups, importedGroups) { const local = Array.isArray(localGroups) ? localGroups : []; const imported = Array.isArray(importedGroups) ? importedGroups : []; const result = []; const usedImported = new Set(); local.forEach((localGroup) => { const localDesc = localGroup.desc || localGroup.name || ''; let matched = false; for (let i = 0; i < imported.length; i++) { if (usedImported.has(i)) continue; const impGroup = imported[i]; const impDesc = impGroup.desc || impGroup.name || ''; if (localDesc && impDesc && localDesc === impDesc) { matched = true; usedImported.add(i); const localCookies = Array.isArray(localGroup) ? localGroup : (localGroup.cookies || []); const impCookies = Array.isArray(impGroup) ? impGroup : (impGroup.cookies || []); const mergedCookies = Array.from(new Set([...localCookies, ...impCookies])); if (Array.isArray(localGroup) && !localGroup.desc) { result.push(mergedCookies); } else { const mergedGroup = { ...localGroup, ...impGroup, desc: impDesc || localDesc, cookies: mergedCookies }; // 配色单独判定(spread 会让远端 color 无条件覆盖,包括用空串清空本地自定义色) const colorState = resolveMarkedGroupColorState(localGroup, impGroup); if (colorState) { mergedGroup.color = colorState.color; mergedGroup.colorAt = colorState.colorAt; } result.push(mergedGroup); } break; } } if (!matched) result.push(localGroup); }); imported.forEach((impGroup, i) => { if (!usedImported.has(i)) result.push(impGroup); }); return result; } function mergeBlockedCookies(localGroups, importedGroups) { return mergeMarkedGroups(localGroups, importedGroups); } function mergeWhitelistGroupsForImport(localGroups, importedGroups) { const combined = [...(Array.isArray(localGroups) ? localGroups : []), ...(Array.isArray(importedGroups) ? importedGroups : [])]; return spData('mergeThreadCookieWhitelistGroups', combined).groups; } function mergeBlockedKeywords(localValue, importedValue) { const localGroups = spData('normalizeBlockedKeywordGroups', localValue); const importedGroups = spData('normalizeBlockedKeywordGroups', importedValue); const allLocalKws = new Set(spData('flattenBlockedKeywords', localGroups)); const localRegexKeys = new Set( localGroups.filter((g) => g.regex).map((g) => String(g.value || '')) ); const result = localGroups.map((g) => ({ value: g.value, regex: !!g.regex })); importedGroups.forEach((impGroup) => { if (impGroup.regex) { const key = String(impGroup.value || ''); if (key && !localRegexKeys.has(key)) { result.push({ value: impGroup.value, regex: true }); localRegexKeys.add(key); } return; } const impKws = Utils.strToList(impGroup.value); if (!impKws.length) return; const hasNew = impKws.some((kw) => !allLocalKws.has(kw)); if (hasNew) { result.push({ value: impGroup.value, regex: false }); impKws.forEach((kw) => allLocalKws.add(kw)); } }); return result; } function mergeSettingsForFullImport(localSettings, importedSettings) { const result = Object.assign({}, localSettings); FULL_IMPORT_DIRECT_OVERRIDE_KEYS.forEach((key) => { if (key in importedSettings) result[key] = importedSettings[key]; }); result.favoriteThreads = mergeFavoriteThreads(localSettings.favoriteThreads, importedSettings.favoriteThreads); result.subscriptionFeeds = mergeSubscriptionFeeds(localSettings.subscriptionFeeds, importedSettings.subscriptionFeeds); result.blockedKeywords = mergeBlockedKeywords(localSettings.blockedKeywords, importedSettings.blockedKeywords); result.markedGroups = mergeMarkedGroups(localSettings.markedGroups, importedSettings.markedGroups); result.blockedCookies = mergeBlockedCookies(localSettings.blockedCookies, importedSettings.blockedCookies); result.threadCookieWhitelistGroups = mergeWhitelistGroupsForImport(localSettings.threadCookieWhitelistGroups, importedSettings.threadCookieWhitelistGroups); // 默认订阅号:按 updatedAt 新者胜(从未设置过则任一方有值即覆盖) const defFeed = mergeDefaultSubscriptionFeedSetting(localSettings, importedSettings); result.defaultSubscriptionFeedUuid = defFeed.uuid; result.defaultSubscriptionFeedUpdatedAt = defFeed.updatedAt; // 顺序传播:按 groupOrderMeta 的 at 新者胜重排六个分组(静默,不弹窗) mergeGroupOrdersInto(result, localSettings, importedSettings); return result; } // WebDAV「保留本地上传」专用:标量项以本地为准(result 以 localSettings 为底), // 复杂项(分组类)无论方向都合并,避免上传全量覆盖时丢失远端分组 function mergeSettingsForWebdavUpload(localSettings, remoteSettings) { const result = Object.assign({}, localSettings); if (!remoteSettings || typeof remoteSettings !== 'object') return result; result.favoriteThreads = mergeFavoriteThreads(localSettings.favoriteThreads, remoteSettings.favoriteThreads); result.subscriptionFeeds = mergeSubscriptionFeeds(localSettings.subscriptionFeeds, remoteSettings.subscriptionFeeds); result.blockedKeywords = mergeBlockedKeywords(localSettings.blockedKeywords, remoteSettings.blockedKeywords); result.markedGroups = mergeMarkedGroups(localSettings.markedGroups, remoteSettings.markedGroups); result.blockedCookies = mergeBlockedCookies(localSettings.blockedCookies, remoteSettings.blockedCookies); result.threadCookieWhitelistGroups = mergeWhitelistGroupsForImport(localSettings.threadCookieWhitelistGroups, remoteSettings.threadCookieWhitelistGroups); // 默认订阅号:同 full-import,按 updatedAt 新者胜 const defFeed = mergeDefaultSubscriptionFeedSetting(localSettings, remoteSettings); result.defaultSubscriptionFeedUuid = defFeed.uuid; result.defaultSubscriptionFeedUpdatedAt = defFeed.updatedAt; // 顺序传播:同 full-import,按 groupOrderMeta 的 at 新者胜重排 mergeGroupOrdersInto(result, localSettings, remoteSettings); return result; } function mergeThreadHistoryStore(localStore, importedStore) { // 本地导入/导出合并(非 WebDAV):浏览次数直接累加;最新浏览时间/串内最远取较大值,首访取较早值 const local = normalizeThreadHistoryStore(localStore); const imported = normalizeThreadHistoryStore(importedStore); const result = Object.assign({}, local); result.items = Object.assign({}, local.items); result.index = Object.assign({}, local.index); Object.keys(imported.items).forEach((key) => { const impItem = imported.items[key]; if (!result.items[key]) { result.items[key] = impItem; result.index[key] = imported.index[key] || buildThreadHistoryIndexEntry(impItem); } else { const localItem = result.items[key]; const impNewer = (Number(impItem.lastVisitedAt) || 0) >= (Number(localItem.lastVisitedAt) || 0); const newer = impNewer ? impItem : localItem; result.items[key] = { ...localItem, ...impItem, firstVisitedAt: Math.min(Number(localItem.firstVisitedAt) || Infinity, Number(impItem.firstVisitedAt) || Infinity), lastVisitedAt: Math.max(Number(localItem.lastVisitedAt) || 0, Number(impItem.lastVisitedAt) || 0), page: Math.max(Number(localItem.page) || 0, Number(impItem.page) || 0), maxVisitedPage: Math.max(Number(localItem.maxVisitedPage) || 0, Number(impItem.maxVisitedPage) || 0), visitCount: (Number(localItem.visitCount) || 0) + (Number(impItem.visitCount) || 0), lastScrollY: newer.lastScrollY != null ? newer.lastScrollY : (localItem.lastScrollY != null ? localItem.lastScrollY : impItem.lastScrollY), title: (newer.title || '').trim() ? newer.title : (localItem.title || impItem.title), name: (newer.name || '').trim() ? newer.name : (localItem.name || impItem.name), }; result.index[key] = buildThreadHistoryIndexEntry(result.items[key]); // index 为纯派生数据: 随合并后的 item 重建, 防止 lastVisitedAt 失步 } }); result.order = Object.keys(result.items) .sort((a, b) => (Number(result.items[b].lastVisitedAt) || 0) - (Number(result.items[a].lastVisitedAt) || 0)); return result; } // WebDAV 浏览历史合并:基于“各端上次参与同步时的基线”计算独立贡献,避免直接累加导致多端计数翻倍 // baseline.threadHistory[key].count = 该端上次参与 WebDAV 同步后本地 visitCount(从未同步过 → 0 → 全量计入) function mergeThreadHistoryStoreWebdav(localStore, remoteStore, baselines) { const local = normalizeThreadHistoryStore(localStore); const remote = normalizeThreadHistoryStore(remoteStore); const result = Object.assign({}, local); result.items = Object.assign({}, local.items); result.index = Object.assign({}, local.index); // ── 墓碑合并: deletedAt 取 max, record 取较新方, purged/revivedAt 双向传播 ── result.tombstones = Object.assign({}, local.tombstones || {}); Object.keys(remote.tombstones || {}).forEach((tKey) => { const rt = remote.tombstones[tKey]; const lt = result.tombstones[tKey]; if (!lt) { result.tombstones[tKey] = rt; return; } const newerDeletedAt = Math.max(Number(rt.deletedAt) || 0, Number(lt.deletedAt) || 0); const newer = (Number(rt.deletedAt) || 0) >= (Number(lt.deletedAt) || 0) ? rt : lt; const purged = !!(rt.purged || lt.purged); const revivedAt = Math.max(Number(rt.revivedAt) || 0, Number(lt.revivedAt) || 0) || null; const mergedTomb = { deletedAt: newerDeletedAt, origin: newer.origin || 'local', purged, revivedAt }; if (!purged && !revivedAt && newer.record) mergedTomb.record = newer.record; result.tombstones[tKey] = mergedTomb; }); // 混跑防线+压制: 对存在本地墓碑(未复活)的 key, 过滤远端条目—— // lastVisitedAt > deletedAt 视为删除后的新段(参与合并), 否则为旧版传回的删除前数据(丢弃) const effectiveRemoteItems = {}; Object.keys(remote.items || {}).forEach((key) => { const t = result.tombstones[key]; if (t && !t.revivedAt && ((Number(remote.items[key].lastVisitedAt) || 0) <= (Number(t.deletedAt) || 0))) return; effectiveRemoteItems[key] = remote.items[key]; }); const baselineMap = (baselines && baselines.threadHistory) || {}; const keys = new Set(Object.keys(local.items).concat(Object.keys(effectiveRemoteItems))); const localTombsForItems = result.tombstones; keys.forEach((key) => { // 本端条目被远端墓碑压制: 条目早于删除时刻 → 应用删除(从合并结果移除) const ownTomb = localTombsForItems[key]; if (ownTomb && !ownTomb.revivedAt && !effectiveRemoteItems[key] && ((Number(result.items[key] && result.items[key].lastVisitedAt) || 0) <= (Number(ownTomb.deletedAt) || 0))) { delete result.items[key]; delete result.index[key]; return; } const localItem = local.items[key]; const remoteItem = effectiveRemoteItems[key]; const baseCount = Number((baselineMap[key] && baselineMap[key].count) || 0); const localCount = Number(localItem && localItem.visitCount) || 0; const remoteCount = Number(remoteItem && remoteItem.visitCount) || 0; // 独立贡献 = 本地当前值 - 上次参与同步时的值(未同步过则全量);远端值 += 独立贡献 const delta = Math.max(0, localCount - baseCount); const mergedCount = remoteCount + delta; let mergedItem = null; if (remoteItem && localItem) { const impNewer = (Number(remoteItem.lastVisitedAt) || 0) >= (Number(localItem.lastVisitedAt) || 0); const newer = impNewer ? remoteItem : localItem; // page = 最近查看页(允许回退): 双端语义取 lastVisitedAt 较新一端的 page, 不取 max(max 会抹掉本地的主动回翻) const mergedPage = Math.max(1, Number(newer.page) || 1); const mergedMaxVp = Math.max(Number(localItem.maxVisitedPage) || 0, Number(remoteItem.maxVisitedPage) || 0, mergedPage); // url/lastKnownPage 禁止被 ...remoteItem 的过期快照覆盖(曾产生 page=97/url=?page=86 的矛盾记录): // url 与 mergedPage 同步(最近查看页), lastKnownPage 只取 max —— 保证写入存储的记录字段自洽 const mergedLastKp = Math.max(Number(localItem.lastKnownPage) || 0, Number(remoteItem.lastKnownPage) || 0, mergedMaxVp || 0); const mergedUrl = buildThreadHistoryPageUrl( localItem.mode || remoteItem.mode, localItem.threadId || remoteItem.threadId, mergedPage ); mergedItem = { ...localItem, ...remoteItem, firstVisitedAt: Math.min(Number(localItem.firstVisitedAt) || Infinity, Number(remoteItem.firstVisitedAt) || Infinity), lastVisitedAt: Math.max(Number(localItem.lastVisitedAt) || 0, Number(remoteItem.lastVisitedAt) || 0), page: mergedPage, maxVisitedPage: mergedMaxVp, lastKnownPage: mergedLastKp, url: mergedUrl, visitCount: mergedCount, lastScrollY: newer.lastScrollY != null ? newer.lastScrollY : (localItem.lastScrollY != null ? localItem.lastScrollY : remoteItem.lastScrollY), title: (newer.title || '').trim() ? newer.title : (localItem.title || remoteItem.title), name: (newer.name || '').trim() ? newer.name : (localItem.name || remoteItem.name), }; } else if (localItem) { // 远端无此 key(从未有过,或被其他端的裸上传覆盖)时,本地值是唯一真相。 // 不能沿用 mergedCount(=0+本地−基线):已同步过的条目会被算成 0 而清零 mergedItem = Object.assign({}, localItem, { visitCount: localCount }); } else if (remoteItem) { mergedItem = Object.assign({}, remoteItem, { visitCount: mergedCount }); } if (mergedItem) { result.items[key] = mergedItem; result.index[key] = buildThreadHistoryIndexEntry(mergedItem); // index 为纯派生数据: 必须随合并后的 item 重建, 沿用旧值会导致 lastVisitedAt 失步、排序错乱 } }); result.order = Object.keys(result.items) .sort((a, b) => (Number(result.items[b].lastVisitedAt) || 0) - (Number(result.items[a].lastVisitedAt) || 0)); return result; } function getWebdavHistoryBaselines() { try { const v = GM_getValue('xdex_webdav_history_baselines', null); return (v && typeof v === 'object') ? v : {}; } catch (e) { return {}; } } function saveWebdavHistoryBaselinesFromStore(store, existingBaselines) { const baselines = Object.assign({}, existingBaselines || getWebdavHistoryBaselines()); baselines.threadHistory = Object.assign({}, baselines.threadHistory || {}); Object.keys(store.items || {}).forEach((key) => { baselines.threadHistory[key] = { count: Number(store.items[key].visitCount) || 0, at: Date.now() }; }); try { GM_setValue('xdex_webdav_history_baselines', baselines); } catch (e) {} return baselines; } function postHistoryMatchKey(item) { if (!item) return ''; // 已确认记录优先用服务端 No.(跨端稳定);未确认退回 type+resto+内容指纹 const postId = String(item.postId || item.id || '').trim(); if (postId) return 'id:' + postId; const type = normalizePostHistoryType(item.type); const resto = String(item.resto || (type === 'reply' ? item.threadId : '') || '').trim(); const hash = item.contentHash || hashPostHistoryText(item.contentText || item.contentRaw || ''); return 'fp:' + type + ':' + resto + ':' + hash; } function mergePostHistoryStore(localStore, importedStore) { const local = normalizePostHistoryStore(localStore); const imported = normalizePostHistoryStore(importedStore); const result = Object.assign({}, local); result.items = Object.assign({}, local.items); // ── 墓碑合并: deletedAt 取 max, record 取较新方, purged/revivedAt 双向传播 ── result.tombstones = Object.assign({}, local.tombstones || {}); Object.keys(imported.tombstones || {}).forEach((tKey) => { const rt = imported.tombstones[tKey]; const lt = result.tombstones[tKey]; if (!lt) { result.tombstones[tKey] = rt; return; } const newerDeletedAt = Math.max(Number(rt.deletedAt) || 0, Number(lt.deletedAt) || 0); const newer = (Number(rt.deletedAt) || 0) >= (Number(lt.deletedAt) || 0) ? rt : lt; const purged = !!(rt.purged || lt.purged); const revivedAt = Math.max(Number(rt.revivedAt) || 0, Number(lt.revivedAt) || 0) || null; const mergedTomb = { deletedAt: newerDeletedAt, origin: newer.origin || 'local', purged, revivedAt }; if (!purged && !revivedAt && newer.record) mergedTomb.record = newer.record; result.tombstones[tKey] = mergedTomb; }); // 混跑防线: 被本地墓碑(未复活)压制且早于删除时刻的导入条目直接丢弃 Object.keys(imported.items).forEach((impKey) => { const mk = postHistoryMatchKey(imported.items[impKey]); if (!mk) return; const t = result.tombstones[mk]; if (t && !t.revivedAt && ((Number(imported.items[impKey].submittedAt) || Infinity) <= (Number(t.deletedAt) || 0))) { imported.items[impKey] = null; } }); // 本端条目被远端墓碑压制: 早于删除时刻 → 应用删除 Object.keys(result.items).forEach((localKey) => { const mk = postHistoryMatchKey(result.items[localKey]); if (!mk) return; const t = result.tombstones[mk]; if (t && !t.revivedAt && ((Number(result.items[localKey].submittedAt) || Infinity) <= (Number(t.deletedAt) || 0))) { delete result.items[localKey]; } }); const STATUS_PRIORITY = { confirmed: 3, unconfirmed: 2, pending: 1, failed: 0 }; // 归一化匹配索引:避免两端 localId 不同导致同一帖子重复 const matchIndex = new Map(); Object.keys(result.items).forEach((key) => { const mk = postHistoryMatchKey(result.items[key]); if (mk) matchIndex.set(mk, key); }); Object.keys(imported.items).forEach((key) => { const impItem = imported.items[key]; if (!impItem) return; // 已被墓碑混跑防线丢弃 const mk = postHistoryMatchKey(impItem); const matchKey = mk ? matchIndex.get(mk) : null; if (!matchKey || !result.items[matchKey]) { result.items[key] = impItem; if (mk && key) matchIndex.set(mk, key); return; } const localItem = result.items[matchKey]; const localStatus = STATUS_PRIORITY[localItem.status] || 0; const impStatus = STATUS_PRIORITY[impItem.status] || 0; result.items[matchKey] = { ...localItem, ...impItem, id: String(impItem.id || localItem.id || ''), postId: String(impItem.postId || localItem.postId || impItem.id || localItem.id || ''), threadId: String(impItem.threadId || localItem.threadId || ''), resto: String(impItem.resto || localItem.resto || ''), status: impStatus >= localStatus ? impItem.status : localItem.status, page: Math.max(Number(localItem.page) || 0, Number(impItem.page) || 0), submittedAt: Math.min(Number(localItem.submittedAt) || Infinity, Number(impItem.submittedAt) || Infinity), contentText: impItem.contentText || localItem.contentText, forumName: impItem.forumName || localItem.forumName, }; }); result.order = Object.keys(result.items) .sort((a, b) => (Number(result.items[b].submittedAt) || 0) - (Number(result.items[a].submittedAt) || 0)); return result; } function normalizeDraftExportText(text) { return String(text || '') .replace(/^\uFEFF/, '') .replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069]/g, '') .trim(); } function collectDraftsForExport() { const registry = getDraftRegistry(); const items = {}; registry.forEach((key) => { const canonical = key.indexOf('xdex_draft:') === 0 ? key : ('xdex_draft:' + key); const pathname = canonical.slice('xdex_draft:'.length); const raw = readDraftValue(pathname); const clean = normalizeDraftExportText(raw); if (clean) items[canonical] = raw; }); return { registry: Object.keys(items), items, deletions: getDraftDeletionStore() }; } function applyDraftsFromImport(drafts) { if (!drafts || typeof drafts !== 'object') return { imported: 0, overwritten: 0 }; const items = drafts.items || {}; // 远端账本仅用于本轮导入裁决, 不回灌本地: 否则"重新编辑翻案"会被旧账本抹杀 const localDeletionStore = getDraftDeletionStore(); const remoteDeletions = (drafts.deletions && typeof drafts.deletions === 'object') ? drafts.deletions : {}; // 本地草稿命中"本地账本"(本端删除且未翻案) → 删除本地 Object.keys(localDeletionStore).forEach((ck) => { if (items[ck] !== undefined) return; // 远端仍有内容 = 重新编辑翻案, 保留 let v = ''; try { v = GM_getValue(ck, ''); } catch (_) {} if (typeof v === 'string' && v !== '') { try { deleteDraftSafe(ck); } catch (_) {} } }); let imported = 0; let overwritten = 0; const newRegistry = new Set(getDraftRegistry()); Object.keys(items).forEach((key) => { const rawPath = String(key || '').slice('xdex_draft:'.length); // ★ 远端键规范化:旧槽(/t/{tid}/page/N 等)并入串级单槽;非串内页(/f/ 等)跳过 const canonicalPath = normalizeDraftKeyFromPath(rawPath) || (rawPath.indexOf('/t/') === 0 ? rawPath : ''); if (!canonicalPath) return; const ck = getDraftStorageKey(canonicalPath); // 远端已删除(账本有) → 压制; // 远端无删除账本但本地残留删除账本 → 本地账本失效放行(远端有内容=翻案) if (remoteDeletions[ck] || remoteDeletions[canonicalDraftKey(key)]) return; const clean = normalizeDraftExportText(items[key]); if (!clean) return; const existing = readDraftValue(canonicalPath); if (existing === items[key]) return; if (existing) overwritten++; else imported++; GM_setValue(ck, items[key]); newRegistry.add(ck); removeDraftDeletion(ck); // 远端翻案内容落地时撤销本地残留账本 }); saveDraftRegistry(Array.from(newRegistry)); return { imported, overwritten }; } function mergeKaomojiStats(localStats, importedStats) { const local = (localStats && typeof localStats === 'object') ? localStats : {}; const imported = (importedStats && typeof importedStats === 'object') ? importedStats : {}; const result = Object.assign({}, local); Object.keys(imported).forEach((key) => { const impVal = imported[key]; const localVal = result[key]; if (!localVal) { result[key] = impVal; } else if (typeof impVal === 'object' && typeof localVal === 'object') { const localCount = Number(localVal.count) || 0; const impCount = Number(impVal.count) || 0; const localLast = Number(localVal.lastUsed) || 0; const impLast = Number(impVal.lastUsed) || 0; result[key] = { count: localCount + impCount, lastUsed: Math.max(localLast, impLast) }; } }); return result; } function collectFullExportPayload(selection) { const payload = {}; if (selection.settings) { payload.myScriptSettings = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); } if (selection.threadHistory) { payload.threadHistory = normalizeThreadHistoryStore(GM_getValue(THREAD_HISTORY_STORAGE_KEY, null)); } if (selection.postHistory) { payload.postHistory = normalizePostHistoryStore(GM_getValue(POST_HISTORY_STORAGE_KEY, null)); } if (selection.drafts) { payload.drafts = collectDraftsForExport(); } if (selection.kaomojiStats) { payload.kaomojiStats = GM_getValue('kaomojiUsageStats', {}); } if (selection.cookiePrefs) { payload.cookiePrefs = GM_getValue('xdex_thread_cookie_prefs', {}); } if (selection.webdav) { try { payload.webdavConfig = GM_getValue(WEBDAV_CONFIG_KEY, null); } catch (e) { payload.webdavConfig = null; } } return payload; } function buildFullExportFile(selection) { const payload = collectFullExportPayload(selection); const now = new Date(); const timestamp = now.toISOString().replace(/[:]/g, '-').replace(/\..+/, ''); const scriptVersion = typeof VERSION !== 'undefined' ? VERSION : (typeof GM_info !== 'undefined' ? GM_info.script.version : 'unknown'); const platform = typeof GM_info !== 'undefined' && GM_info.scriptHandler ? 'userscript' : 'extension'; return { file: { meta: { format: 'xdex-full-export', schemaVersion: FULL_EXPORT_SCHEMA_VERSION, exportedAt: now.toISOString(), source: 'nmbxd-EX', scriptVersion, platform }, selection, strategyHints: { settings: 'merge', threadHistory: 'merge', postHistory: 'merge', drafts: 'override-imported', kaomojiStats: 'accumulate', cookiePrefs: 'merge', webdav: 'override' }, summary: { threadHistoryCount: selection.threadHistory ? Object.keys((payload.threadHistory || {}).items || {}).length : 0, postHistoryCount: selection.postHistory ? Object.keys((payload.postHistory || {}).items || {}).length : 0, draftCount: selection.drafts ? (payload.drafts.registry || []).length : 0, kaomojiStatsEntries: selection.kaomojiStats ? Object.keys(payload.kaomojiStats || {}).length : 0, cookiePrefsCount: selection.cookiePrefs ? Object.keys(payload.cookiePrefs || {}).length : 0, webdavConfig: selection.webdav && payload.webdavConfig ? 1 : 0, }, payload }, filename: `x岛-ex-full-export-${timestamp}.json` }; } function downloadFullExportFile(fileData) { const blob = new Blob([JSON.stringify(fileData.file, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = fileData.filename; a.click(); URL.revokeObjectURL(a.href); } function parseFullExportFile(text) { let parsed; try { parsed = JSON.parse(text); } catch (e) { return { valid: false, error: '文件格式错误,无法解析 JSON' }; } if (!parsed || typeof parsed !== 'object') return { valid: false, error: '文件内容无效' }; const meta = parsed.meta || {}; if (meta.format !== 'xdex-full-export') return { valid: false, error: '不支持的文件格式,请使用 X岛-EX 使用数据导出文件' }; if (!meta.schemaVersion || meta.schemaVersion > FULL_EXPORT_SCHEMA_VERSION) return { valid: false, error: `不支持的 schema 版本: ${meta.schemaVersion}` }; if (!parsed.payload || typeof parsed.payload !== 'object') return { valid: false, error: '文件缺少 payload 数据' }; return { valid: true, data: parsed }; } function applyFullImportPayload(importData, options) { options = options || {}; const payload = importData.payload; const report = {}; if (payload.myScriptSettings) { const local = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); const merged = mergeSettingsForFullImport(local, payload.myScriptSettings); GM_setValue(SettingPanel.key, merged); report.settings = { mode: 'merge', changed: true }; } if (payload.threadHistory) { const local = normalizeThreadHistoryStore(GM_getValue(THREAD_HISTORY_STORAGE_KEY, null)); let merged; let mode = 'merge'; if (options.threadHistoryMode === 'webdav-delta') { // WebDAV:基于基线计算独立贡献,不直接累加 let baselines = getWebdavHistoryBaselines(); // 迁移兜底:基线从未建立(功能首次引入/基线丢失)但本端此前同步过时,本地存储已是 // 历史合并结果;若按 baseline=0 计算,整份旧计数会被当成新增,导致各端逐轮倍增 if (!baselines.threadHistory) { let syncedBefore = 0; try { syncedBefore = Number((GM_getValue(WEBDAV_CONFIG_KEY, null) || {}).lastSyncAt) || 0; } catch (e) {} if (syncedBefore > 0) { baselines = saveWebdavHistoryBaselinesFromStore(local, baselines); console.log('[webdav] 基线缺失且本端曾同步过:已按当前本地值重建基线,避免旧计数被计为新增'); } } merged = mergeThreadHistoryStoreWebdav(local, normalizeThreadHistoryStore(payload.threadHistory), baselines); merged = setThreadHistoryStore(merged); saveWebdavHistoryBaselinesFromStore(merged, baselines); mode = 'webdav-delta'; } else { // 本地导入导出:浏览次数累加 merged = mergeThreadHistoryStore(local, payload.threadHistory); merged = setThreadHistoryStore(merged); } report.threadHistory = { mode, count: Object.keys(merged.items || {}).length }; } if (payload.postHistory) { const local = normalizePostHistoryStore(GM_getValue(POST_HISTORY_STORAGE_KEY, null)); const merged = setPostHistoryStore(mergePostHistoryStore(local, payload.postHistory)); // 经 setter 写入:同步刷新归一化缓存并广播变更 report.postHistory = { mode: 'merge', count: Object.keys(merged.items || {}).length }; } // 草稿暂为纯本地数据: WebDAV 下载侧跳过导入, 远端旧草稿不再回流 if (payload.drafts && options.threadHistoryMode !== 'webdav-delta') { const result = applyDraftsFromImport(payload.drafts); report.drafts = { mode: 'override-imported', imported: result.imported, overwritten: result.overwritten }; } if (payload.kaomojiStats) { const local = GM_getValue('kaomojiUsageStats', {}); let merged; if (options.threadHistoryMode === 'webdav-delta') { // WebDAV 双向同步:按各端基线计算独立贡献后加算,避免每轮把远端全量当新增而重复计数; // lastUsed 恒取两端较大值(时间不可累加) const baselines = getWebdavHistoryBaselines(); const kaoBase = (baselines && baselines.kaomojiStats) || {}; merged = {}; const keys = new Set(Object.keys(local).concat(Object.keys(payload.kaomojiStats))); keys.forEach((key) => { const l = local[key]; const r = payload.kaomojiStats[key]; if (!r) { merged[key] = l; return; } if (!l || typeof l !== 'object' || typeof r !== 'object') { merged[key] = r; return; } const baseCount = Number((kaoBase[key] && kaoBase[key].count) || 0); const delta = Math.max(0, (Number(l.count) || 0) - baseCount); merged[key] = { count: (Number(r.count) || 0) + delta, lastUsed: Math.max(Number(l.lastUsed) || 0, Number(r.lastUsed) || 0) }; }); // 推进基线到合并值(与浏览历史同模式:回推失败时由外层快照统一回滚) const newBaselines = Object.assign({}, baselines || {}); newBaselines.kaomojiStats = {}; Object.keys(merged).forEach((key) => { const entry = merged[key]; newBaselines.kaomojiStats[key] = { count: (entry && Number(entry.count)) || 0, at: Date.now() }; }); try { GM_setValue('xdex_webdav_history_baselines', newBaselines); } catch (e) {} } else { // 手动导入导出:保持原有纯累加语义 merged = mergeKaomojiStats(local, payload.kaomojiStats); } GM_setValue('kaomojiUsageStats', merged); report.kaomojiStats = { mode: 'accumulate', changed: true }; } if (payload.cookiePrefs) { const local = GM_getValue('xdex_thread_cookie_prefs', {}); const merged = Object.assign({}, local, payload.cookiePrefs); GM_setValue('xdex_thread_cookie_prefs', merged); report.cookiePrefs = { mode: 'merge', count: Object.keys(merged).length }; } if (payload.webdavConfig && typeof payload.webdavConfig === 'object') { try { GM_setValue(WEBDAV_CONFIG_KEY, payload.webdavConfig); } catch (e) {} report.webdavConfig = { mode: 'override', changed: true }; } return report; } // 暴露给 tag29 WebDAV 使用:本组工具位于 render 嵌套作用域,顶层不可见 window.__xdexWebdavUtils = { buildFullExportFile, parseFullExportFile, applyFullImportPayload, mergeThreadHistoryStoreWebdav, mergePostHistoryStore, mergeSettingsForWebdavUpload, getWebdavHistoryBaselines, saveWebdavHistoryBaselinesFromStore }; // === 使用数据导入导出 end === function buildJSONC(state) { const filtered = {}; for (const [k, v] of Object.entries(state)) { if (!FIXED_KEYS.has(k)) filtered[k] = v; // 不清理零宽字符,保留用户原始数据 } const meta = { _meta: { version: (typeof VERSION !== 'undefined' ? VERSION : GM_info.script.version), exportedAt: new Date().toISOString(), source: 'nmbxd-EX' }, settings: filtered, webdavConfig: (function () { try { return GM_getValue(WEBDAV_CONFIG_KEY, null); } catch (e) { return null; } })() }; const lines = JSON.stringify(meta, null, 2).split('\n'); // 在 _meta 前加注释,在 settings 闭合括号前加尾随逗号 let result = lines.map((line, i) => { if (i === 0) return line; // 开头 { if (/"_meta"/.test(line)) return ' // X岛-EX 配置文件\n' + line; if (/^}$/.test(line.trim())) return line; // 最外层 } return line; }).join('\n'); // 给 settings 对象的最后一个字段后加尾随逗号 result = result.replace(/("settings":\s*\{[\s\S]*?)(\n\s*\}\s*\n\s*\})/, (m, inner, close) => { const trimmed = inner.replace(/,\s*$/, ''); return trimmed + ',\n' + close.replace(/^\n/, ''); }); return result; } function validateImport(incoming) { const defaults = SettingPanel.defaults; if (typeof incoming !== 'object' || incoming === null) { toast('配置内容无效'); return null; } const validated = {}; let skipped = 0; for (const [key, val] of Object.entries(incoming)) { if (FIXED_KEYS.has(key)) continue; // 固定项直接跳过 if (!(key in defaults)) continue; // 未知字段跳过 if (Array.isArray(defaults[key]) && !Array.isArray(val)) { skipped++; continue; } if (typeof val !== typeof defaults[key]) { skipped++; continue; } validated[key] = val; } return Object.assign({}, defaults, validated); } function handleImportText(text) { if (!text || !text.trim()) { toast('内容为空'); return; } // 只清理文本开头的 BOM,不清理内容中的零宽字符(用户数据可能包含零宽空格) text = text.replace(/^\uFEFF/, ''); let parsed; try { parsed = parseJSONC(text); } catch (e) { toast('配置文件格式错误'); return; } const incoming = parsed.settings || parsed; const merged = validateImport(incoming); if (!merged) return; // WebDAV 配置随设置文件一并导入(独立 GM key,不进 settings 白名单) if (parsed && parsed.webdavConfig && typeof parsed.webdavConfig === 'object') { try { GM_setValue(WEBDAV_CONFIG_KEY, parsed.webdavConfig); } catch (e) {} } SettingPanel.__pendingImport = merged; // 显示保存按钮 $('#btn_sp_importExport').removeClass('xdex-btn-hidden'); toast('格式正确,请点击[应用]'); } // 从剪贴板导入 $('#sp_importClipboard').off('click').on('click', async () => { try { const text = await navigator.clipboard.readText(); handleImportText(text); } catch (e) { toast('无法读取剪贴板,请先点击页面后再试'); } }); // 导出到剪贴板 $('#sp_exportClipboard').off('click').on('click', async () => { try { const jsonc = buildJSONC(SettingPanel.state); await navigator.clipboard.writeText(jsonc); toast('已复制到剪贴板'); } catch (e) { toast('复制失败'); } }); // 从文件导入 $('#sp_importFile').off('click').on('click', () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,.jsonc,.txt'; input.onchange = () => { const file = input.files && input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => handleImportText(reader.result); reader.onerror = () => toast('文件读取失败'); reader.readAsText(file); }; input.click(); }); // 导出为文件 $('#sp_exportFile').off('click').on('click', () => { try { const jsonc = buildJSONC(SettingPanel.state); const blob = new Blob([jsonc], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'x岛-ex-settings-v' + (typeof VERSION !== 'undefined' ? VERSION : GM_info.script.version) + '.jsonc'; a.click(); URL.revokeObjectURL(a.href); toast('配置已导出'); } catch (e) { toast('导出失败'); } }); // 导入/导出折叠块的"保存"按钮 $('#btn_sp_importExport').off('click').on('click', e => { e.stopPropagation(); if (SettingPanel.__pendingImport) { GM_setValue(SettingPanel.key, SettingPanel.__pendingImport); SettingPanel.state = SettingPanel.__pendingImport; delete SettingPanel.__pendingImport; $('#btn_sp_importExport').addClass('xdex-btn-hidden'); toast('配置已导入,即将刷新'); setTimeout(() => location.reload(), 800); } else { toast('没有待导入的配置'); } }); // 关闭面板时清除暂存 const _origClose = $('#sp_close,#sp_cover').off.bind($('#sp_close,#sp_cover'), 'click'); delete SettingPanel.__pendingImport; // ── 使用数据导入导出 ── $('#btn_sp_fullExport_reset').off('click').on('click', (e) => { e.stopPropagation(); const selection = { settings: $('#sp_fullExport_settings').is(':checked'), threadHistory: $('#sp_fullExport_threadHistory').is(':checked'), postHistory: $('#sp_fullExport_postHistory').is(':checked'), drafts: $('#sp_fullExport_drafts').is(':checked'), kaomojiStats: $('#sp_fullExport_kaomojiStats').is(':checked'), cookiePrefs: $('#sp_fullExport_cookiePrefs').is(':checked'), webdav: $('#sp_fullExport_webdav').is(':checked') }; if (!Object.values(selection).some(Boolean)) { toast('请至少勾选一项'); return; } const parts = []; if (selection.settings) parts.push('设置(恢复默认)'); if (selection.threadHistory) parts.push('浏览历史'); if (selection.postHistory) parts.push('发言历史'); if (selection.drafts) parts.push('草稿'); if (selection.kaomojiStats) parts.push('颜文字统计'); if (selection.cookiePrefs) parts.push('串内饼干偏好'); if (selection.webdav) parts.push('WebDAV 配置'); if (!window.confirm(`确定要清除以下项目的全部内容吗?\n\n${parts.join('、')}\n\n清除后页面将自动刷新。`)) return; try { if (selection.settings) GM_setValue(SettingPanel.key, {}); if (selection.threadHistory) setThreadHistoryStore(null); if (selection.postHistory) setPostHistoryStore(null); if (selection.drafts) { getDraftRegistry().forEach((key) => { try { GM_deleteValue(key); } catch (_) {} }); saveDraftRegistry([]); } if (selection.kaomojiStats) GM_setValue('kaomojiUsageStats', {}); if (selection.cookiePrefs) GM_setValue('xdex_thread_cookie_prefs', {}); if (selection.webdav) GM_setValue(WEBDAV_CONFIG_KEY, {}); toast('已清除所选项目,即将刷新'); setTimeout(() => location.reload(), 800); } catch (err) { toast('清除失败: ' + (err.message || err)); } }); $('#btn_sp_fullExport_export').off('click').on('click', (e) => { e.stopPropagation(); const selection = { settings: $('#sp_fullExport_settings').is(':checked'), threadHistory: $('#sp_fullExport_threadHistory').is(':checked'), postHistory: $('#sp_fullExport_postHistory').is(':checked'), drafts: $('#sp_fullExport_drafts').is(':checked'), kaomojiStats: $('#sp_fullExport_kaomojiStats').is(':checked'), cookiePrefs: $('#sp_fullExport_cookiePrefs').is(':checked'), webdav: $('#sp_fullExport_webdav').is(':checked') }; if (!Object.values(selection).some(Boolean)) { toast('请至少勾选一项'); return; } const fileData = buildFullExportFile(selection); downloadFullExportFile(fileData); toast('使用数据已导出'); }); $('#btn_sp_fullExport_import').off('click').on('click', (e) => { e.stopPropagation(); const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = () => { const file = input.files && input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const result = parseFullExportFile(reader.result); if (!result.valid) { toast(result.error); return; } SettingPanel.__pendingFullImport = result.data; renderFullImportPreview(result.data); }; reader.readAsText(file); }; input.click(); }); function renderFullImportPreview(data) { const $preview = $('#sp_fullExport_import_preview').empty().show(); const meta = data.meta || {}; const summary = data.summary || {}; let html = '<div style="font-size:12px;color:var(--foreground, #333);">'; html += `<div>来源版本: ${meta.scriptVersion || '?'} | 导出时间: ${meta.exportedAt ? new Date(meta.exportedAt).toLocaleString() : '?'}</div>`; html += '<div style="margin-top:4px;">包含数据:</div><ul style="margin:2px 0;padding-left:20px;">'; if (summary.threadHistoryCount) html += `<li>浏览历史: ${summary.threadHistoryCount} 条</li>`; if (summary.postHistoryCount) html += `<li>发言历史: ${summary.postHistoryCount} 条</li>`; if (summary.draftCount) html += `<li>草稿: ${summary.draftCount} 条</li>`; if (summary.kaomojiStatsEntries) html += `<li>颜文字统计: ${summary.kaomojiStatsEntries} 项</li>`; if (summary.cookiePrefsCount) html += `<li>串内饼干偏好: ${summary.cookiePrefsCount} 条</li>`; if (summary.webdavConfig) html += '<li>WebDAV 配置(覆盖导入)</li>'; if (data.selection && data.selection.settings) html += '<li>设置配置(合并导入)</li>'; html += '</ul>'; html += '<div style="color:var(--muted-foreground, #666);margin-top:4px;">导入策略: 设置合并、历史合并、草稿冲突时导入端覆盖、颜文字累加</div>'; html += '</div>'; const $btn = $('<button style="margin-top:6px;padding:4px 10px;">应用导入</button>'); $btn.on('click', () => { if (!SettingPanel.__pendingFullImport) { toast('无可导入数据'); return; } if (!window.confirm('确定要导入使用数据吗?\n导入完成后页面将自动刷新。')) return; const report = applyFullImportPayload(SettingPanel.__pendingFullImport); SettingPanel.__pendingFullImport = null; $preview.hide(); const parts = []; if (report.settings) parts.push('设置已合并'); if (report.threadHistory) parts.push(`浏览历史 ${report.threadHistory.count} 条`); if (report.postHistory) parts.push(`发言历史 ${report.postHistory.count} 条`); if (report.drafts) parts.push(`草稿 ${report.drafts.imported} 条导入${report.drafts.overwritten ? `, ${report.drafts.overwritten} 条覆盖` : ''}`); if (report.kaomojiStats) parts.push('颜文字已累加'); toast(`使用数据导入完成: ${parts.join('、')},即将刷新`); setTimeout(() => location.reload(), 800); }); $preview.html(html).append($btn); } $('#sp_apply').off('click').on('click', ()=>{ collectReloadRequiredSettingsFromPanel(); let valid = true; const blockedKeywords = collectBlockedKeywordGroupsFromPanel(); if (!blockedKeywords) return; this.state.blockedKeywords = blockedKeywords; const favoriteThreads = spUi('collectFavoriteThreadsFromPanel'); if (!favoriteThreads) return; this.state.favoriteThreads = favoriteThreads; this.state.replyModeDefault = $('#sp_replyModeDefault').val(); this.state.replyExtraDefault = $('#sp_replyExtraDefault').val(); this.state.kaomojiSort = $('#sp_kaomojiSort').val() || 'default'; this.state.dockDisplayMode = $('#sp_dockDisplayMode').val() || 'hover'; this.state.applyImageHideMode = $('#sp_applyImageHideMode').val() || 'default'; this.state.imageViewerSeparators = $('#sp_imageViewerSeparators').is(':checked'); this.state.threadCookieWhitelistDisplayMode = $('#sp_threadCookieWhitelistDisplayMode').val() || 'fold'; this.state.poAnnotationSideDisplayMode = $('#sp_poAnnotationSideDisplayMode').val() || 'collapse'; this.state.timeDisplayMode = ($('#sp_timeDisplayMode').val() === 'exact') ? 'exact' : 'relative'; // 标记分组(双字段结构) const mk = collectMarkedGroupsFromPanel(); if (!mk) return; this.state.markedGroups = mk; // 屏蔽分组(双字段结构) const bk = []; $('#blocked-inputs-container .blocked-row').each((idx, el)=>{ const $row = $(el); const desc = ($row.find('.blocked-desc-input').val() || '').trim(); const cookies = Utils.strToList(($row.find('.blocked-cookies-input').val() || '').trim()); if (!desc && !cookies.length) return; if (!spData('isValidDesc', desc)) { toast(`第${idx + 1}条备注过长`); valid=false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid=false; return false; } if (cookies.some(id=>!Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid=false; return false; } bk.push({ desc, cookies }); }); if (!valid) return; this.state.blockedCookies = bk; const wlg = []; $('#thread-cookie-whitelist-inputs-container .thread-cookie-whitelist-row').each((idx, el) => { const $row = $(el); const desc = ($row.find('.thread-cookie-whitelist-desc-input').val() || '').trim(); const threads = Utils.strToList(($row.find('.thread-cookie-whitelist-threads-input').val() || '').trim()); const cookies = Utils.strToList(($row.find('.thread-cookie-whitelist-cookies-input').val() || '').trim()); if (!desc && !threads.length && !cookies.length) return; if (!spData('isValidDesc', desc)) { toast(`第${idx + 1}条备注过长`); valid = false; return false; } if (!threads.length) { toast(`第${idx + 1}条未指定串号`); valid = false; return false; } if (threads.some(id => !spData('isValidThreadId', id))) { toast(`第${idx + 1}条存在不合法串号`); valid = false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid = false; return false; } if (cookies.some(id => !Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid = false; return false; } wlg.push({ desc, threads, cookies, rowIndex: idx + 1 }); }); if (!valid) return; const { groups: mergedWhitelistGroups, mergeEvents } = spData('mergeThreadCookieWhitelistGroups', wlg); this.state.threadCookieWhitelistGroups = mergedWhitelistGroups; mergeEvents.forEach((event) => { if (event.desc) { toast(`第${event.rowIndex}条(${event.desc})中的串号 ${event.threadId} 已与现有只看规则合并`); } else { toast(`第${event.rowIndex}条的串号 ${event.threadId}(${event.cookies.join(',')})已与现有只看规则合并`); } }); // 原版 // GM_setValue(this.key, this.state); // toast('保存成功,即将刷新页面'); // setTimeout(()=>location.reload(),500); // Edge双重刷新版 // GM_setValue(this.key, this.state); // console.log('GM_setValue执行成功'); // // 立即读取验证 // const saved = GM_getValue(this.key); // console.log('读取验证:', JSON.stringify(saved)); // toast('保存成功,即将刷新页面'); // // Edge 浏览器需要更长的延迟确保存储完成 // setTimeout(() => { // // 再次验证保存是否成功 // const finalCheck = GM_getValue(this.key); // console.log('刷新前最终验证:', JSON.stringify(finalCheck)); // // 两次刷新 // try { // // 设置标记,下一次页面加载时脚本会检测到并执行第二次重载 // localStorage.setItem(this.key + '_doSecondReload', '1'); // } catch (e) { // console.warn('[Settings] set second-reload flag failed', e); // } // location.reload(); // }, 800); // 将延迟从 500ms 增加到 800ms // 当前版本 GM_setValue(this.key, this.state); console.log('GM_setValue执行成功'); // 立即读取验证 const saved = GM_getValue(this.key); console.log('读取验证:', JSON.stringify(saved)); toast('保存成功,即将刷新页面'); // Edge 浏览器需要更长的延迟确保存储完成 setTimeout(() => { // 再次验证保存是否成功 const finalCheck = GM_getValue(this.key); console.log('刷新前最终验证:', JSON.stringify(finalCheck)); // 只刷新一次 location.reload(); }, 800); // 将延迟从 500ms 增加到 800ms }); // 关闭面板 $('#sp_close,#sp_cover').off('click').on('click', e=>{ if (e.target.id==='sp_close' || e.target.id==='sp_cover') $('#sp_cover').fadeOut(); }); //鼠标悬浮在具体功能上显示提示 // ====== 1. 定义功能描述映射表 ====== const spDescriptions = { sp_enableCookieSwitch: '发帖框上方添加饼干切换器,单击即可快速切换饼干。使用前可单击“刷新”以获取当前登陆账户最新饼干列表', sp_enableCookieConfirm: '发送前弹窗显示当前串内各饼干的使用情况,可切换饼干后再发送', sp_enablePaginationDuplication: '在串首页添加页码导航栏,并拓展为最长七个页码按钮', sp_disableWatermark: '取消发图默认勾选的水印选项', sp_updatePreviewCookie: '为“增强X岛匿名版”添加的预览框显示真实饼干', sp_hideEmptyTitleEmail: '隐藏帖内无标题、无名氏和版规提示,优化显示效果,减少版面占用', sp_enableExternalImagePreview: '直接显示外部图床的图片', sp_enableUpdateCheck: '控制是否自动检查脚本更新;关闭后不会发起远程更新请求,也不会继续安排后续检查', sp_enableAutoCookieRefresh: '回到X岛页面后自动刷新饼干,以防错饼', sp_enableAutoCookieRefreshToast: '自动刷新时显示toast提示,触发频率较高,建议关闭', sp_enableSeamlessPaging: '阅读到页面底部时无缝加载下一页并为新页首添加页码提示', sp_enableAutoSeamlessPaging: '滚动到页面底部后自动触发无缝翻页,关闭则可使用按钮手动无缝翻页', sp_enableHDImageAndLayoutFix: 'X岛-揭示板的增强型体验:默认加载原图而非缩略图,并为所有图片添加X岛自带图片控件;调整布局,防止文字与图片溢出', sp_enableImageContextMenu: 'userscript模式:为图片/动图启用自定义右键菜单,关闭后保留浏览器原生图片右键菜单,复制图片过程中需要浏览器窗口在前台。\nextension模式:在浏览器右键菜单中添加“X岛-EX:复制GIF/APNG”按钮,仅用于复制GIF/APNG,在复制GIF/APNG过程中焦点可不在前台', sp_enableLinkBlank: 'X岛-揭示板的增强型体验:串页链接在新标签页打开', sp_enableAutoUrlLinkify: '自动将正文中的网址转换为可点击的新标签页蓝色链接,可与“拓展引用格式”共存', sp_enableQuotePreview: '优化引用弹窗显示,将鼠标悬停出现引用弹窗改为点击显示引用弹窗,引用弹窗可持久存在,支持嵌套、拖拽,点击非引用弹窗区域或ESC键可关闭当前引用弹窗,点击右下角×以关闭全部引用弹窗', sp_extendQuote: '拓展引用格式,支持除“>>No.66994128”标准引用格式外的引用,例如“>>66994128”、“66994128”、“No.66994128”,同样支持“优化引用弹窗”', sp_extendQuoteAvailabilityDetection: '检测引用号对应的串或回复是否存在以及属于什么类型:恢复-默认;不存在-变淡;主串-实线(当前串)/虚线(其他串)。被标记为主串的引用号可从“拓展引用浮窗”中直接跳转', sp_enableCustomDarkTheme: '深色模式(light=浅色 / dark=深色)', sp_threadCookieWhitelistModeEnabled: '只看饼干模式\n折叠:保持原版只看饼干折叠逻辑\n隐藏:未命中的回复直接隐藏\n分栏:重点回复保留在主阅读流,观众回复进入侧栏批注\n可选观众回复的展开/收起', sp_poAnnotationSideDisplayMode: '分栏模式下观众回复栏的显示状态。展开:完整展开;收起:默认高度不超过对应主回复高度,超出部分滚动', sp_toggleSidebar: '来自acVMxuv的自动收起右侧扩展坞侧边栏,鼠标悬停时展开显示', sp_updateReplyNumbers: '添加当页内回复编号显示', sp_replaceRightSidebar: '增强右侧扩展坞功能,点击REPLY按钮打开回复弹窗,点击非回复弹窗区域或ESC键可关闭回复弹窗,另外支持使用CTRL+ENTER发送消息', sp_interceptReplyForm: '拦截回复跳转中间页,使用toast提示发送成功/失败信息', sp_interceptReplyFormUnvcode: '不可明说的功能,请参照https://words-away.typeboom.com/说明', sp_interceptReplyFormU200B: '优先使用插入零宽空格模式而非unvcode替换模式', sp_interceptReplyFormAutoCompress: '自动压缩>2048KB的图片', sp_kaomojiEnhancer: '拓展颜文字功能,添加更多颜文字(部分来自蓝岛),优化选择颜文字弹窗,选择颜文字后可插入光标所在处。支持排序:默认(原顺序)/常用(使用次数高优先)/最近(最近使用优先,未使用保持默认顺序)', sp_highlightPO: '为回复添加Po主标志,PO主回复编号使用角标显示', sp_enhancePostFormLayout: '优化发串/回复表单布局,将“送出”按钮移至颜文字栏目,折叠“标题”“E-mail”“名称”等不常用项目,节省版面,添加标题/作者/E-mail/正文的字数统计与提醒', sp_applyFilters: '标记/屏蔽-饼干/关键词过滤规则\n折叠:匹配到的串/回复显示为可展开的按钮\n隐藏:匹配到的串/回复完全隐藏', sp_enhanceIsland: '增强X岛匿名版:\n1.发串前显示预览:麻麻再也不用担心我的ASCII ART排版失误了,另外支持预览插入图片和外部图床图片;\n2.自动保存编辑:记忆文本框内容(防止屏蔽词导致被吞),可以在翻页等各种页面切换后保存,仅在“回复成功”后删除,按主串号 "/t/xxxx" 分开存储;\n3.追记引用串号:点击串号回复时附加到光标所在处(或替换文本选区),可追记多条引用;\n4.人类友好的时间显示:如“5秒前”、“1小时前”、“昨天”等;\n5.粘贴插入图片:直接粘贴,将自动作为图片插入\n自动添加标题:将po主设置的标题或者第一行文字 + 页码设置为标签页标题', sp_timeDisplayMode: '切换串内时间显示方式。相对时间会在当前可见页面定时刷新;精确时间显示原始发帖时间', sp_replyQuicklyOnBoardPage: '为板块页添加快速回复模式,在板块页即可回串,页面实时更新,无需跳转串内;并额外支持时间线内回串。\n“板块页默认模式”可选“发串/回复”两种模式,“回复默认模式”可选“临时/连续”两种回复模式,临时模式下回复成功即清除回串信息,连续模式可连续回复直到手动清理回串信息,搭配回复浮窗使用效果更佳', sp_enablePostExpand: '为板块页内串添加“展开/收起”按钮,点击即可切换长串的完整显示与折叠显示', sp_searchServiceBy4sY: '官方搜索当前不可用,公告详见:https://www.nmbxd1.com/t/56546294\n替换搜索按钮为来自4sYbzEX的“野生搜索酱”,具体使用方法请查阅原串:https://www.nmbxd.com/t/64792841', sp_enableImageHideMode: '“默认/模糊/无图/Tips”四种模式可选。默认模式不做修改;选择模糊模式时可使用鼠标悬浮暂时预览图片;无图模式隐藏图片;Tips模式随机显示Tips娘,点击后可恢复原图显示', sp_enableFavoriteThreads: '在侧边栏添加常用串,支持串内一键添加,并优先跳转浏览历史中的最近阅读页', sp_enableThreadHistory: '保存浏览历史,支持搜索,可切换多种排序方式。关闭后不再记录新的浏览数据(已有数据保留),侧边栏入口隐藏', sp_enablePostHistory: '保存发言历史,分为“我的主题/我的回复”,并记录回复所在页面,支持搜索,可切换多种排序方式。关闭后不再在发送消息后自动记录(手动添加不受影响,已有数据保留),侧边栏入口隐藏', sp_enableSubscriptionFeed: '使用移动端订阅号进行同步,支持添加多个订阅号', sp_enableThreadExport: '导出串:把当前串(或指定串号)导出为 Markdown / TXT 文件。支持批量串号、页码范围与「只看 PO」;任务在后台按顺序执行,页面可关闭。右侧图标按钮可直接打开导出窗口', sp_enableImageViewerMode: '阅图模式:以瀑布流方式浏览当前串的所有图片,点击单图进入详情,支持旋转、缩放、键盘翻页(←→方向键切换、[]旋转、+-缩放、0复位、↑↓平移)。右侧图标按钮可在设置面板内进入;无图模式下右上角入口会隐藏,可用此按钮作为备选', sp_imageViewerSeparators: '在各页图片之间显示红色分隔线与“第N页”页码标识;关闭以获得更纯净的阅图体验', sp_postAfterAction: '发串成功后的行为:新标签页打开新串,或刷新当前板块页回到顶部', sp_subscriptionFeeds: '管理X岛订阅号,可添加多个订阅号并设置备注,用于在"我的订阅"标签中查看和管理订阅内容', sp_disableAutoQuote: '在类似https://www.nmbxd1.com/t/67024789?page=23&r=68811442等携带r=参数的串中,保留高亮的同时控制其是否在输入框中自动添加引用号,值班室版块默认不生效', sp_autoSelectReportReason: '值班室默认选择“其他”理由,且在值班室版块快捷回复面板中默认为“发串”模式以便提交举报', }; // 更新日志弹窗(放在 spDescriptions 之后,避免引用未定义) if (!document.getElementById('sp_update_log')) { const $log = $( '<div id=\"sp_update_log\" style=\"display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:10001;\">' + '<div style=\"position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(640px, calc(100vw - 48px));max-height:calc(100vh - 96px);display:flex;flex-direction:column;overflow:hidden;background:var(--xdex-sp-panel-bg);border:1px solid var(--xdex-sp-border);border-radius:8px;box-shadow:0 2px 12px var(--xdex-sp-shadow);\">' + '<div style=\"padding:10px 12px;border-bottom:1px solid var(--xdex-sp-border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;\">' + '<span class=\"xdex-update-log-title\" style=\"font-weight:bold;\">更新日志</span>' + '<span id=\"sp_update_log_close\" style=\"cursor:pointer;\">✕</span>' + '</div>' + '<div class=\"xdex-update-log-body\" style=\"padding:12px;font-size:12px;white-space:pre-line;overflow:auto;flex:1 1 auto;min-height:0;\">' + (CHANGELOG || '暂无更新说明') + '</div>' + '<div class=\"xdex-update-log-actions\" style=\"display:none;padding:0 12px 12px;gap:8px;justify-content:flex-end;flex-shrink:0;\">' + '<button id=\"sp_update_log_update_now\" style=\"padding:4px 10px;\">立即更新</button>' + '<button id=\"sp_update_log_ignore_version\" style=\"padding:4px 10px;\">忽略此版本</button>' + '<button id=\"sp_update_log_dismiss_today\" style=\"padding:4px 10px;\">今日关闭</button>' + '</div>' + '</div>' + '</div>' ); $('body').append($log); $('#sp_update_log_close,#sp_update_log').on('click', (e) => { if (e.target.id === 'sp_update_log') { spUpdate('closeUpdateLogDialog', { treatAsDismiss: false, reason: 'overlay' }); return; } if (e.target.id === 'sp_update_log_close') { const isRemoteMode = $('#sp_update_log').attr('data-update-mode') === 'remote'; spUpdate('closeUpdateLogDialog', { treatAsDismiss: isRemoteMode, reason: 'close-button' }); } }); $('#sp_update_log_update_now').on('click', () => { const state = spUpdate('getUpdateCheckState'); spUpdate('closeUpdateLogDialog', { reason: 'update-now' }); spUpdate('flashFooterUpdateHighlight', state.pendingUpdateSource || ''); }); $('#sp_update_log_ignore_version').on('click', () => { const state = spUpdate('getUpdateCheckState'); if (state.pendingUpdateVersion) { state.ignoredVersion = state.pendingUpdateVersion; spUpdate('setUpdateCheckState', state); spUpdate('updateSettingsButtonBadge', state); } spUpdate('closeUpdateLogDialog', { reason: 'ignore-version' }); }); $('#sp_update_log_dismiss_today').on('click', () => { spUpdate('closeUpdateLogDialog', { treatAsDismiss: true, reason: 'dismiss-today' }); }); } // 版本号同步到当前脚本版本 try { const ver = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) ? GM_info.script.version : ''; if (ver) $('#sp_version_link').text('v' + ver); } catch (e) {} $('#sp_version_link').off('click').on('click', (e) => { e.preventDefault(); spUpdate('openUpdateLogDialog', 'local'); }); // ====== 2. 创建 tooltip 元素并添加样式 ====== if (!$('#sp_tooltip').length) { $('body').append('<div id="sp_tooltip"></div>'); const tooltipStyle = ` #sp_tooltip { position: fixed; max-width: 260px; background: rgba(0,0,0,0.85); color: #fff; padding: 6px 10px; border-radius: 4px; font-size: 12px; line-height: 1.4; pointer-events: none; display: none; z-index: 100000; box-shadow: 0 2px 6px rgba(0,0,0,0.3); transition: opacity 0.15s ease; opacity: 0; white-space: pre-line; } #sp_tooltip.show { display: block; opacity: 1; } `; $('<style>').text(tooltipStyle).appendTo('head'); } // ====== 3. 绑定事件到每个设置项 ====== $('#sp_checkbox_container input[type=checkbox]').each(function(){ const id = this.id; const desc = spDescriptions[id]; if (!desc) return; const $label = $(this).next('label'); const $target = $label.length ? $label : $(this); $target.on('mouseenter', function(e){ $('#sp_tooltip').text(desc) .css({ top: e.clientY + 12, left: e.clientX + 12 }) .addClass('show'); }).on('mousemove', function(e){ const offsetX = 40; // 横向偏移量 const offsetY = 0; // 纵向偏移量 $('#sp_tooltip').css({ top: e.clientY + offsetY, // 保持纵向偏移 left: e.clientX + offsetX // 横向偏移改大,右移更多 }); }).on('mouseleave', function(){ $('#sp_tooltip').removeClass('show'); }); }); // 给额外的标题绑定提示 $('#sp_replyQuicklyOnBoardPage').each(function(){ const id = this.id; const desc = spDescriptions[id]; if (!desc) return; $(this).on('mouseenter', function(e){ $('#sp_tooltip').text(desc) .css({ top: e.clientY + 12, left: e.clientX + 12 }) .addClass('show'); }).on('mousemove', function(e){ $('#sp_tooltip').css({ top: e.clientY, left: e.clientX + 40 }); }).on('mouseleave', function(){ $('#sp_tooltip').removeClass('show'); }); }); }, syncInputs() { // 勾选框 [ 'enableCookieSwitch', 'enableCookieConfirm', 'disableWatermark', 'enablePaginationDuplication', 'updatePreviewCookie', 'hideEmptyTitleEmail', 'enableExternalImagePreview', 'enableUpdateCheck', 'enableAutoCookieRefresh', 'enableAutoCookieRefreshToast', 'interceptReplyFormUnvcode', 'interceptReplyFormU200B', 'interceptReplyFormAutoCompress', 'enableSeamlessPaging', 'enableAutoSeamlessPaging', 'enableHDImageAndLayoutFix', 'enableImageContextMenu', 'enableThreadHistory', 'enablePostHistory', 'enableLinkBlank', 'enableAutoUrlLinkify', 'enableQuotePreview', 'enableImageHideMode', 'imageViewerSeparators', 'extendQuote', 'extendQuoteAvailabilityDetection', 'enablePostExpandAll', 'toggleSidebar', 'disableAutoQuote', ].forEach(k=> $('#sp_'+k).prop('checked', this.state[k])); $('#sp_enableCustomDarkTheme').prop('checked', this.state.enableCustomDarkTheme === 'dark'); // 深色开关视觉状态:日/月图标 + 轨道底色 try { syncDarkToggleVisual(); } catch (e) {} // 二次确认饼干联动:快捷切换饼干关闭时禁用 $('#sp_enableCookieConfirm').prop('disabled', !this.state.enableCookieSwitch); // 固定启用项:始终显示为开启 $('#sp_enableImageHideMode').prop('checked', true); $('#sp_applyImageHideMode').val(this.state.applyImageHideMode || 'default'); $('#sp_blockDisplayMode').val(this.state.blockDisplayMode || 'fold'); $('#sp_threadCookieWhitelistDisplayMode').val(this.state.threadCookieWhitelistDisplayMode || 'fold'); $('#sp_poAnnotationSideDisplayMode').val(this.state.poAnnotationSideDisplayMode || 'collapse'); $('#sp_kaomojiSort').val(this.state.kaomojiSort || 'default'); $('#sp_dockDisplayMode').val(this.state.dockDisplayMode || 'fixed'); $('#sp_timeDisplayMode').val(this.state.timeDisplayMode === 'exact' ? 'exact' : 'relative'); // 标记分组 const groupsM = this.state.markedGroups.length ? this.state.markedGroups : [{desc:'',cookies:[]}]; const $m = $('#marked-inputs-container').empty(); groupsM.forEach((g, idx)=>{ $m.append(spUi('buildCookieGroupTwoFieldRowHtml', 'marked', idx + 1, g)); }); // 屏蔽分组 const groupsB = this.state.blockedCookies.length ? this.state.blockedCookies : [{desc:'',cookies:[]}]; const $b = $('#blocked-inputs-container').empty(); groupsB.forEach((g, idx)=>{ $b.append(spUi('buildCookieGroupTwoFieldRowHtml', 'blocked', idx + 1, g)); }); const groupsW = this.state.threadCookieWhitelistGroups.length ? this.state.threadCookieWhitelistGroups : [{threads:[],cookies:[]}]; const $w = $('#thread-cookie-whitelist-inputs-container').empty(); groupsW.forEach((g, idx)=>{ $w.append(spUi('buildThreadCookieWhitelistRowHtml', idx + 1, g)); }); const groupsK = spData('normalizeBlockedKeywordGroups', this.state.blockedKeywords); const $k = $('#blocked-keyword-inputs-container').empty(); (groupsK.length ? groupsK : [{value:''}]).forEach((g, idx)=>{ $k.append(spUi('buildBlockedKeywordGroupRowHtml', idx + 1, g)); }); const favoriteThreads = spData('normalizeFavoriteThreads', this.state.favoriteThreads); const $favoriteThreads = $('#favorite-thread-inputs-container').empty(); (favoriteThreads.length ? favoriteThreads : [{desc:'', threadId:''}]).forEach((item, idx)=>{ $favoriteThreads.append(spUi('buildFavoriteThreadRowHtml', idx + 1, item)); }); const subscriptionFeeds = Array.isArray(this.state.subscriptionFeeds) ? this.state.subscriptionFeeds : []; const $feeds = $('#subscription-feed-inputs-container').empty(); (subscriptionFeeds.length ? subscriptionFeeds : [{desc:'', uuid:''}]).forEach((item, idx)=>{ $feeds.append(spUi('buildSubscriptionFeedRowHtml', idx + 1, item)); }); $('#sp_replyModeDefault').val(this.state.replyModeDefault); $('#sp_replyExtraDefault').val(this.state.replyExtraDefault); // 初始折叠与按钮隐藏 // 保存/同步时不再强制收起折叠区:模板本身已是 display:none + xdex-inv, // 若这里每次 hide/加 inv,会把用户刚展开的「标记饼干/屏蔽关键词」等分组立刻折回去。 // $('.sp_fold_body').hide(); // $('#btn_group_marked,#btn_sp_marked,#btn_group_blocked,#btn_sp_blocked,#btn_group_threadCookieWhitelist,#btn_sp_threadCookieWhitelist,#btn_group_blockedKeywords,#btn_sp_blockedKeywords,#btn_group_favoriteThreads,#btn_sp_favoriteThreads,#btn_group_subscriptionFeeds,#btn_sp_subscriptionFeeds,#btn_sp_importExport,#btn_sp_fullExport_reset,#btn_sp_fullExport_export,#btn_sp_fullExport_import').addClass('xdex-inv'); $('#sp_replyModeDefault').val(this.state.replyModeDefault); $('#sp_replyExtraDefault').val(this.state.replyExtraDefault); } }; /* -------------------------------------------------- * tag 2. 回复编号 * -------------------------------------------------- */ // 数字样式:包裹为『n』 const circledNumber = n => `『${n}』`; function updateReplyNumbers() { // 遍历每一个页面的回复区(含无缝加载的) $('.h-threads-item-replies').each(function () { let effectiveCount = 0; $(this).find('.h-threads-item-reply-icon').each(function () { const $reply = $(this).closest('[data-threads-id]'); if ($reply.attr('data-threads-id') === '9999999') { // 特殊:小提示串号 -> 编号 0 $(this).text(circledNumber(0)); } else { // 普通回复 -> 依次递增 effectiveCount++; $(this).text(circledNumber(effectiveCount)); } }); }); } /* -------------------------------------------------- * tag 3. 饼干标记 / 屏蔽 逻辑 * -------------------------------------------------- */ // 多分组标记时依次使用的背景色(可扩充) const markColors = [ '#66CCFF','#00FFCC','#EE0000','#006666','#0080FF','#FFFF00', '#39C5BB','#9999FF','#FF4004','#3399FF','#D80000','#F6BE71', '#EE82EE','#FFA500','#FFE211','#FAAFBE','#0000FF' ]; // 解析"最后一个冒号分隔"的分组:返回 {desc, list} function parseDescAndListByLastColon(raw) { const idx = Math.max(raw.lastIndexOf(':'), raw.lastIndexOf(':')); let desc = ''; let cookiePart = ''; if (idx > 0) { // 有冒号:冒号前是备注/说明,冒号后是饼干 desc = raw.slice(0, idx).trim(); cookiePart = raw.slice(idx + 1).trim(); } else { // 没有冒号:整个字符串都是饼干 cookiePart = raw.trim(); } const list = Utils.strToList(cookiePart); return { desc, list }; } // 校验分组说明长度(<=20 字符;满足“10个汉字/20个英文字符”的近似约束) function isValidDesc(desc) { return !desc || desc.length <= 20; } function isValidHexColor(color) { return /^#[0-9A-Fa-f]{6}$/.test(color); } function normalizeHexColor(color) { if (typeof color !== 'string') return ''; const trimmed = color.trim(); return isValidHexColor(trimmed) ? trimmed.toUpperCase() : ''; } function clampColorChannel(value, min, max) { return Math.min(max, Math.max(min, value)); } function rgbToHex(rgb) { if (!rgb) return ''; const r = clampColorChannel(Math.round(rgb.r || 0), 0, 255); const g = clampColorChannel(Math.round(rgb.g || 0), 0, 255); const b = clampColorChannel(Math.round(rgb.b || 0), 0, 255); return `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('').toUpperCase()}`; } function hexToRgb(color) { const normalized = normalizeHexColor(color); if (!normalized) return null; return { r: parseInt(normalized.slice(1, 3), 16), g: parseInt(normalized.slice(3, 5), 16), b: parseInt(normalized.slice(5, 7), 16), }; } function hsvToRgb(h, s, v) { const hue = ((Number(h) % 360) + 360) % 360; const sat = clampColorChannel(Number(s), 0, 1); const val = clampColorChannel(Number(v), 0, 1); const c = val * sat; const x = c * (1 - Math.abs((hue / 60) % 2 - 1)); const m = val - c; let r1 = 0; let g1 = 0; let b1 = 0; if (hue < 60) { r1 = c; g1 = x; } else if (hue < 120) { r1 = x; g1 = c; } else if (hue < 180) { g1 = c; b1 = x; } else if (hue < 240) { g1 = x; b1 = c; } else if (hue < 300) { r1 = x; b1 = c; } else { r1 = c; b1 = x; } return { r: Math.round((r1 + m) * 255), g: Math.round((g1 + m) * 255), b: Math.round((b1 + m) * 255), }; } function rgbToHsv(r, g, b) { const red = clampColorChannel(Number(r), 0, 255) / 255; const green = clampColorChannel(Number(g), 0, 255) / 255; const blue = clampColorChannel(Number(b), 0, 255) / 255; const max = Math.max(red, green, blue); const min = Math.min(red, green, blue); const delta = max - min; let h = 0; if (delta) { if (max === red) h = 60 * (((green - blue) / delta) % 6); else if (max === green) h = 60 * (((blue - red) / delta) + 2); else h = 60 * (((red - green) / delta) + 4); } if (h < 0) h += 360; return { h, s: max === 0 ? 0 : delta / max, v: max, }; } function hexToHsv(color) { const rgb = hexToRgb(color); return rgb ? rgbToHsv(rgb.r, rgb.g, rgb.b) : { h: 0, s: 0, v: 1 }; } function hsvToHex(h, s, v) { return rgbToHex(hsvToRgb(h, s, v)); } function parseRgbColorString(value) { if (typeof value !== 'string') return null; const match = value.trim().match(/^rgb\s*\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i); if (!match) return null; const r = Number(match[1]); const g = Number(match[2]); const b = Number(match[3]); if ([r, g, b].some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null; return { r, g, b }; } function formatRgbColor(rgb) { if (!rgb) return ''; return `rgb(${clampColorChannel(Math.round(rgb.r || 0), 0, 255)}, ${clampColorChannel(Math.round(rgb.g || 0), 0, 255)}, ${clampColorChannel(Math.round(rgb.b || 0), 0, 255)})`; } function getMarkedGroupEffectiveColor(group, index) { return normalizeHexColor(group && group.color) || markColors[index % markColors.length]; } function parseThreadCookieWhitelistRule(raw) { const idx = Math.max(raw.lastIndexOf(':'), raw.lastIndexOf(':')); let threadPart = ''; let cookiePart = ''; if (idx > 0) { threadPart = raw.slice(0, idx).trim(); cookiePart = raw.slice(idx + 1).trim(); } else { threadPart = raw.trim(); } return { threads: Utils.strToList(threadPart), cookies: Utils.strToList(cookiePart), }; } function buildThreadCookieWhitelistRowHtml(index, group = {}) { const desc = group.desc || ''; const threadText = Array.isArray(group.threads) ? group.threads.join(',') : ''; const cookieText = Array.isArray(group.cookies) ? group.cookies.join(',') : ''; return ` <div class="thread-cookie-whitelist-row" style="position:relative;margin:10px 0 8px;"> <span style="position:absolute;top:-9px;left:10px;display:inline-block;padding:0 6px;font-size:12px;line-height:18px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;z-index:1;">#${index}</span> ${buildRowDragHandleHtml()} <button type="button" class="thread-cookie-whitelist-delete" style="position:absolute;top:-9px;right:10px;width:20px;height:20px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:1;">${XDEX_SVG_X}</button> <div style="display:grid;grid-template-columns:minmax(0,0.9fr) minmax(0,1.25fr) minmax(0,1.35fr);gap:8px;align-items:flex-start;border:1px solid #bfa58f;border-radius:6px;padding:12px 10px 10px 26px;background:rgba(255,255,255,0.18);box-sizing:border-box;width:100%;"> <input class="thread-cookie-whitelist-desc-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="备注(可选)" value="${Utils.escapeHTML ? Utils.escapeHTML(desc) : desc}"> <input class="thread-cookie-whitelist-threads-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="串号1,串号2" value="${Utils.escapeHTML ? Utils.escapeHTML(threadText) : threadText}"> <input class="thread-cookie-whitelist-cookies-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="3-8位饼干ID1,饼干ID2" value="${Utils.escapeHTML ? Utils.escapeHTML(cookieText) : cookieText}"> </div> </div>`; } function buildCookieGroupRowHtml(type, index, value, placeholder) { return ` <div class="${type}-row" style="position:relative;margin:10px 0 8px;"> <span style="position:absolute;top:-9px;left:10px;display:inline-block;padding:0 6px;font-size:12px;line-height:18px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;z-index:1;">#${index}</span> ${buildRowDragHandleHtml()} <button type="button" class="${type}-delete" style="position:absolute;top:-9px;right:10px;width:20px;height:20px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:1;">${XDEX_SVG_X}</button> <div style="border:1px solid #bfa58f;border-radius:6px;padding:12px 10px 10px 26px;background:rgba(255,255,255,0.18);box-sizing:border-box;width:100%;"> <input class="${type}-input" style="width:100%;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="${placeholder}" value="${Utils.escapeHTML ? Utils.escapeHTML(value || '') : (value || '')}"> </div> </div>`; } function getBlockedKeywordPlaceholder(regexOn) { return regexOn ? '正则模式:整组作为一条正则,如 foo|bar 或 (串|回复)\\d+' : '关键词1,关键词2;7/8/9位数字同时也作为串号/回复号匹配'; } function buildBlockedKeywordGroupRowHtml(index, group = {}) { const keywordText = typeof group.value === 'string' ? group.value : (Array.isArray(group.keywords) ? group.keywords.join(',') : ''); const regexOn = isBlockedKeywordRegexGroup(group); const placeholder = getBlockedKeywordPlaceholder(regexOn); const checkedAttr = regexOn ? ' checked' : ''; const safeValue = Utils.escapeHTML ? Utils.escapeHTML(keywordText || '') : (keywordText || ''); const safePlaceholder = Utils.escapeHTML ? Utils.escapeHTML(placeholder) : placeholder; // 右侧开关样式对齐设置页 xdex-switch;默认关闭=普通逗号关键词,开启=整组正则 return ` <div class="blocked-keyword-row" style="position:relative;margin:10px 0 8px;" data-regex="${regexOn ? '1' : '0'}"> <span style="position:absolute;top:-9px;left:10px;display:inline-block;padding:0 6px;font-size:12px;line-height:18px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;z-index:1;">#${index}</span> ${buildRowDragHandleHtml()} <button type="button" class="blocked-keyword-delete" style="position:absolute;top:-9px;right:10px;width:20px;height:20px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:1;">${XDEX_SVG_X}</button> <div style="border:1px solid #bfa58f;border-radius:6px;padding:12px 10px 10px 26px;background:rgba(255,255,255,0.18);box-sizing:border-box;width:100%;"> <div style="display:flex;align-items:center;gap:8px;"> <input class="blocked-keyword-input" style="flex:1;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="${safePlaceholder}" value="${safeValue}"> <div style="display:flex;align-items:center;gap:6px;flex:0 0 auto;white-space:nowrap;"> <input type="checkbox" id="blocked-keyword-regex-${index}" class="xdex-switch blocked-keyword-regex" role="switch"${checkedAttr}> <label for="blocked-keyword-regex-${index}" style="font-size:12px;color:#6f5d50;user-select:none;">正则</label> </div> </div> </div> </div>`; } // ===== 分组顺序传播(拖拽排序作为可传播设置) ===== // 设计:state.groupOrderMeta[分组字段] = { order: [语义键...], at: 时间戳 } // - 语义键与该分组合并函数的匹配键一致(双键:desc 优先,cookies 兜底) // - 合并规则:两端都有则 at 新者胜;order 中不存在的项追加末尾;已失效的键丢弃 // - 静默传播:groupOrderMeta 在 WEBDAV_SETTINGS_SKIP_KEYS 中,不触发冲突弹窗 const GROUP_ORDER_FIELDS = ['markedGroups', 'blockedCookies', 'threadCookieWhitelistGroups', 'blockedKeywords', 'favoriteThreads', 'subscriptionFeeds']; // 分组类型 → 键提取(与合并函数保持一致) function groupOrderKeysOf(field, item) { if (!item || typeof item !== 'object') return []; const keys = []; const desc = String(item.desc || item.name || '').trim(); if (desc) keys.push('d:' + desc); // 各分组的存储结构(与 collect/save 函数写入的字段一致): // markedGroups/blockedCookies: {desc, color?, cookies:[...]} // threadCookieWhitelistGroups: {desc, threads:[...], cookies:[...]} // blockedKeywords: {value, regex} // favoriteThreads: {desc, threadId} // subscriptionFeeds: {desc, uuid} const joinList = (v) => (Array.isArray(v) ? v : String(v == null ? '' : v).split(/\s*,\s*/)) .map((x) => String(x).trim()).filter(Boolean).join(','); if (field === 'markedGroups' || field === 'blockedCookies') { const ck = joinList(item.cookies); if (ck) keys.push('c:' + ck); } else if (field === 'threadCookieWhitelistGroups') { const th = joinList(item.threads); if (th) keys.push('t:' + th); } else if (field === 'favoriteThreads') { const tid = String(item.threadId || '').trim(); if (tid) keys.push('i:' + tid); } else if (field === 'subscriptionFeeds') { const uuid = String(item.uuid || '').trim(); if (uuid) keys.push('u:' + uuid); } else if (field === 'blockedKeywords') { const v = String(item.value || '').trim(); if (v) keys.push('k:' + (item.regex ? 'r:' : '') + v); } return keys; } // 数组 → 键序列(取每项的首个可用键,用于写入 order) function groupOrderSnapshot(field, arr) { const out = []; (Array.isArray(arr) ? arr : []).forEach((item) => { const keys = groupOrderKeysOf(field, item); if (keys.length) out.push(keys[0]); }); return out; } // 按 order 键序列重排数组:命中的按 order 排序,未命中的追加末尾(保持原相对顺序) function applyGroupOrder(field, arr, order) { const list = Array.isArray(arr) ? arr.slice() : []; if (!Array.isArray(order) || !order.length) return list; const used = new Set(); const result = []; order.forEach((key) => { for (let i = 0; i < list.length; i++) { if (used.has(i)) continue; const keys = groupOrderKeysOf(field, list[i]); // 双键匹配:order 中的键命中该项任一键即可(兼容「后补备注」后键变化) if (keys.indexOf(key) !== -1) { used.add(i); result.push(list[i]); return; } } }); list.forEach((item, i) => { if (!used.has(i)) result.push(item); }); return result; } // 合并两端顺序元数据:at 新者胜;返回 {order, at} 或 null(两端都无) function mergeGroupOrderMeta(localMeta, remoteMeta) { const l = localMeta && typeof localMeta === 'object' ? localMeta : null; const r = remoteMeta && typeof remoteMeta === 'object' ? remoteMeta : null; if (!l && !r) return null; if (!l) return r; if (!r) return l; const lat = Number(l.at) || 0; const rat = Number(r.at) || 0; if (rat > lat) return r; if (lat > rat) return l; // 时间相同:优先更长(覆盖更多项)的序列,保证结果稳定 const lo = Array.isArray(l.order) ? l.order : []; const ro = Array.isArray(r.order) ? r.order : []; return ro.length > lo.length ? r : l; } // 对六个分组字段统一应用顺序合并(local/remote 均为 settings 对象) function mergeGroupOrdersInto(result, localSettings, remoteSettings) { const localMeta = (localSettings && localSettings.groupOrderMeta) || {}; const remoteMeta = (remoteSettings && remoteSettings.groupOrderMeta) || {}; const mergedMeta = {}; GROUP_ORDER_FIELDS.forEach((field) => { const picked = mergeGroupOrderMeta(localMeta[field], remoteMeta[field]); if (picked) mergedMeta[field] = picked; if (result[field] && picked) { result[field] = applyGroupOrder(field, result[field], picked.order); } }); if (Object.keys(mergedMeta).length) result.groupOrderMeta = mergedMeta; } // ===== 分组行拖拽排序(标记/屏蔽饼干、只看饼干、屏蔽关键词、常用串、订阅号共用) ===== // 设计: // - 手柄只在行左上角「六点」区域响应,输入框/色块/删除按钮完全不受影响; // - 拖动中实时 insertBefore 重排(视觉即时反馈),松手后重编号徽标并调 save 回调落盘; // - save 失败(校验不过)时回调返回 false → 调用方负责 syncInputs() 回滚到 state 顺序; // - 与 beginMarkedColorDrag 并存:事件命名空间独立(.rowDrag vs .markedColorDrag)。 function buildRowDragHandleHtml() { return `<span class="xdex-row-drag-handle" title="拖动排序" style="position:absolute;left:4px;top:50%;transform:translateY(-50%);width:14px;height:22px;display:flex;align-items:center;justify-content:center;cursor:grab;z-index:2;color:#8a7566;opacity:.8;user-select:none;touch-action:none;">${XDEX_SVG_GRIP}</span>`; } // 行内序号徽标重写(拖动后调用;仅改显示文本,存储中无序号字段) function renumberRowBadges($container, rowSelector) { $container.find(rowSelector).each(function (idx) { const $row = $(this); const $badge = $row.children('span').first(); if ($badge.length && /^#\d+$/.test(($badge.text() || '').trim())) $badge.text('#' + (idx + 1)); }); } // 从行 DOM 提取语义键(与 groupOrderKeysOf 的格式一致:d:/c:/t:/i:/u:/k:) // 用于拖拽结束时按新 DOM 顺序记录 groupOrderMeta(此刻 state 仍是旧顺序) // class 名与各 collect 函数读取的输入框保持一致 function rowOrderKeyFromDom(field, $row) { try { let desc = ''; if (field === 'markedGroups') desc = String($row.find('.marked-desc-input').val() || '').trim(); else if (field === 'blockedCookies') desc = String($row.find('.blocked-desc-input').val() || '').trim(); else if (field === 'threadCookieWhitelistGroups') desc = String($row.find('.thread-cookie-whitelist-desc-input').val() || '').trim(); else if (field === 'blockedKeywords') desc = String($row.find('.blocked-keyword-desc-input').val() || '').trim(); else if (field === 'favoriteThreads') desc = String($row.find('.favorite-thread-desc-input').val() || '').trim(); else if (field === 'subscriptionFeeds') desc = String($row.find('.subscription-feed-desc-input').val() || '').trim(); if (desc) return 'd:' + desc; if (field === 'markedGroups' || field === 'blockedCookies') { const sel = field === 'markedGroups' ? '.marked-cookies-input' : '.blocked-cookies-input'; const ck = String($row.find(sel).val() || '').trim().split(/\s*,\s*/).filter(Boolean).join(','); if (ck) return 'c:' + ck; } else if (field === 'threadCookieWhitelistGroups') { const t = String($row.find('.thread-cookie-whitelist-threads-input').val() || '').trim().split(/\s*,\s*/).filter(Boolean).join(','); if (t) return 't:' + t; } else if (field === 'favoriteThreads') { // 与 collectFavoriteThreadsFromPanel 一致:用规范化后的 threadId(DOM 里是原始输入) const raw = String($row.find('.favorite-thread-id-input').val() || '').trim(); let tid = raw; try { if (typeof normalizeFavoriteThreadInput === 'function') tid = normalizeFavoriteThreadInput(raw) || raw; } catch (e) {} if (tid) return 'i:' + tid; } else if (field === 'subscriptionFeeds') { const u = String($row.find('.subscription-feed-uuid-input').val() || '').trim(); if (u) return 'u:' + u; } else if (field === 'blockedKeywords') { const v = String($row.find('.blocked-keyword-input').val() || '').trim(); const isRegex = $row.attr('data-regex') === '1'; if (v) return 'k:' + (isRegex ? 'r:' : '') + v; } } catch (e) {} return null; } // 通用拖拽安装:containerSelector 内 rowSelector 行按手柄拖动排序 // field: 分组字段名(写入 state.groupOrderMeta[field] 供跨端传播顺序) // onSorted: 排序落盘回调,返回 false 表示失败(由调用方回滚) function installRowDragSorting({ containerSelector, rowSelector, field, onSorted }) { const $container = $(containerSelector); if (!$container.length) return; $container.off('mousedown.rowDrag touchstart.rowDrag', '.xdex-row-drag-handle') .on('mousedown.rowDrag touchstart.rowDrag', '.xdex-row-drag-handle', function (startEvent) { const isTouch = startEvent.type.indexOf('touch') === 0; if (!isTouch && startEvent.button !== 0) return; // 仅左键 startEvent.preventDefault(); startEvent.stopPropagation(); const $handle = $(this); const $row = $handle.closest(rowSelector); if (!$row.length || $container.find(rowSelector).length < 2) return; // 单行无需排序 const rowHeight = $row.outerHeight(true); const dragOffsetY = (isTouch ? (startEvent.originalEvent.touches[0].clientY) : startEvent.clientY) - $row[0].getBoundingClientRect().top; $row.css({ opacity: 0.55, boxShadow: '0 6px 16px rgba(0,0,0,0.25)' }); $handle.css('cursor', 'grabbing'); $('body').css('user-select', 'none'); const moveNs = isTouch ? 'touchmove.rowDrag' : 'mousemove.rowDrag'; const endNs = isTouch ? 'touchend.rowDrag touchcancel.rowDrag' : 'mouseup.rowDrag'; const getPoint = (evt) => { const oe = evt.originalEvent || evt; const touch = oe.touches && oe.touches[0]; const changed = oe.changedTouches && oe.changedTouches[0]; return touch || changed || oe; }; const onMove = (evt) => { evt.preventDefault(); const point = getPoint(evt); const y = point.clientY; // 以「被拖行中点」判定目标插入位置 const draggedMid = y - dragOffsetY + rowHeight / 2; let $target = null; $container.find(rowSelector).each(function () { if (this === $row[0]) return; const rect = this.getBoundingClientRect(); if (draggedMid < rect.top + rect.height / 2) { $target = $(this); return false; } }); if ($target && $target.length) { if ($target[0] !== $row[0].nextElementSibling) $target.before($row); } else { $container.append($row); } }; const onEnd = () => { $(document).off('.rowDrag'); $row.css({ opacity: '', boxShadow: '' }); $handle.css('cursor', 'grab'); $('body').css('user-select', ''); renumberRowBadges($container, rowSelector); // 先按新 DOM 顺序记录 groupOrderMeta(save 成功后即随设置落盘;失败则随后回滚) let wroteOrder = false; if (field && typeof SettingPanel !== 'undefined' && SettingPanel.state) { try { // ★ 必须从 DOM 提取(此刻 state[field] 仍是旧顺序); // 每行的语义键从行内输入框读取,与 groupOrderKeysOf 的键格式保持一致 const order = []; $container.find(rowSelector).each(function () { const key = rowOrderKeyFromDom(field, $(this)); if (key) order.push(key); }); if (order.length) { if (!SettingPanel.state.groupOrderMeta || typeof SettingPanel.state.groupOrderMeta !== 'object') { SettingPanel.state.groupOrderMeta = {}; } SettingPanel.state.groupOrderMeta[field] = { order, at: Date.now() }; wroteOrder = true; } } catch (e) {} } if (typeof onSorted === 'function') { const ok = onSorted(); if (ok === false) { // 校验失败:save 未写入也未重绘 → 回滚 DOM 与顺序元数据到 state 顺序 if (wroteOrder && field && SettingPanel.state.groupOrderMeta) { try { delete SettingPanel.state.groupOrderMeta[field]; } catch (e) {} } try { SettingPanel.syncInputs(); } catch (e) {} } } }; $(document).off('.rowDrag').on(moveNs, onMove).on(endNs, onEnd); }); } function buildCookieGroupTwoFieldRowHtml(type, index, group = {}) { const desc = group.desc || ''; const cookieText = Array.isArray(group.cookies) ? group.cookies.join(',') : ''; const color = normalizeHexColor(group.color); const effectiveColor = type === 'marked' ? getMarkedGroupEffectiveColor(group, index - 1) : ''; const gridTemplateColumns = type === 'marked' ? 'minmax(0,2fr) minmax(0,3fr) 34px' : 'minmax(0,2fr) minmax(0,3fr)'; const markedSwatchHtml = type === 'marked' ? ` <div class="marked-color-cell" style="position:relative;min-width:0;"> <input type="hidden" class="marked-color-value" value="${Utils.escapeHTML ? Utils.escapeHTML(color) : color}"> <button type="button" class="marked-color-swatch" aria-label="设置标记颜色" data-default-color="${effectiveColor}" style="width:100%;height:31px;padding:0;border:1px solid #a98f7a;border-radius:8px;box-sizing:border-box;background:${effectiveColor};cursor:pointer;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.42);" ></button> <div class="marked-color-popover" style="display:none;position:absolute;right:0;z-index:12;width:220px;padding:10px;border:1px solid #bfa58f;border-radius:10px;background:var(--xdex-sp-panel-bg);box-shadow:0 8px 18px var(--xdex-sp-shadow);"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;"> <div class="marked-color-preview" style="width:30px;height:30px;flex:0 0 30px;border:1px solid #a98f7a;border-radius:8px;background:${effectiveColor};box-shadow:inset 0 0 0 1px rgba(255,255,255,0.42);"></div> <div style="min-width:0;flex:1;"> <div class="marked-color-status" style="font-size:12px;line-height:1.25;color:#6f5d50;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">#RRGGBB,自定义为空时按默认序列</div> <div class="marked-color-default-hint" style="font-size:11px;line-height:1.25;color:#8a7768;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">默认 ${effectiveColor}</div> </div> </div> <div class="marked-color-sv" style="position:relative;height:110px;border:1px solid #a98f7a;border-radius:8px;cursor:crosshair;overflow:hidden;background:linear-gradient(to top, #000 0%, transparent 100%), linear-gradient(to right, #fff 0%, hsl(0,100%,50%) 100%);margin-bottom:8px;"> <div class="marked-color-sv-thumb" style="position:absolute;left:100%;top:0;width:12px;height:12px;margin-left:-6px;margin-top:-6px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,0.35);pointer-events:none;background:transparent;"></div> </div> <div class="marked-color-hue" style="position:relative;height:14px;border:1px solid #a98f7a;border-radius:999px;cursor:pointer;overflow:hidden;background:linear-gradient(to right, #FF0000 0%, #FFFF00 16.66%, #00FF00 33.33%, #00FFFF 50%, #0000FF 66.66%, #FF00FF 83.33%, #FF0000 100%);margin-bottom:8px;"> <div class="marked-color-hue-thumb" style="position:absolute;left:0;top:50%;width:12px;height:12px;margin-left:-6px;margin-top:-6px;border:2px solid #fff;border-radius:50%;box-sizing:border-box;box-shadow:0 0 0 1px rgba(0,0,0,0.35);pointer-events:none;background:transparent;"></div> </div> <div style="display:flex;gap:6px;margin-bottom:8px;"> <button type="button" class="marked-color-format" data-format="hex" style="flex:1;padding:4px 0;border:1px solid #7da6bf;border-radius:8px;background:#66CCFF;color:#fff;cursor:pointer;">HEX</button> <button type="button" class="marked-color-format" data-format="rgb" style="flex:1;padding:4px 0;border:1px solid #a98f7a;border-radius:8px;background:#F0E0D6;color:#6f5d50;cursor:pointer;">RGB</button> </div> <input class="marked-color-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;margin-bottom:8px;" placeholder="#66CCFF" value="${Utils.escapeHTML ? Utils.escapeHTML(color) : color}"> <div style="display:flex;gap:6px;"> <button type="button" class="marked-color-clear" style="flex:1;padding:4px 0;border:1px solid #a98f7a;border-radius:8px;background:#F0E0D6;cursor:pointer;">恢复默认</button> <button type="button" class="marked-color-save" style="flex:1;padding:4px 0;border:1px solid #7da6bf;border-radius:8px;background:#66CCFF;color:#fff;cursor:pointer;">保存</button> </div> </div> </div>` : ''; return ` <div class="${type}-row" style="position:relative;margin:10px 0 8px;"> <span style="position:absolute;top:-9px;left:10px;display:inline-block;padding:0 6px;font-size:12px;line-height:18px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;z-index:1;">#${index}</span> ${buildRowDragHandleHtml()} <button type="button" class="${type}-delete" style="position:absolute;top:-9px;right:10px;width:20px;height:20px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:1;">${XDEX_SVG_X}</button> <div style="display:grid;grid-template-columns:${gridTemplateColumns};gap:8px;align-items:flex-start;border:1px solid #bfa58f;border-radius:6px;padding:12px 10px 10px 26px;background:rgba(255,255,255,0.18);box-sizing:border-box;width:100%;"> <input class="${type}-desc-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="备注(可选)" value="${Utils.escapeHTML ? Utils.escapeHTML(desc) : desc}"> <input class="${type}-cookies-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="3-8位饼干ID1,饼干ID2" value="${Utils.escapeHTML ? Utils.escapeHTML(cookieText) : cookieText}"> ${markedSwatchHtml} </div> </div>`; } function normalizeThreadCookieWhitelistGroups(val) { if (!Array.isArray(val)) return []; return val.map((g) => ({ desc: typeof g.desc === 'string' && isValidDesc(g.desc.trim()) ? g.desc.trim() : '', threads: Array.isArray(g.threads) ? [...new Set(g.threads.filter(isValidThreadId))] : [], cookies: Array.isArray(g.cookies) ? [...new Set(g.cookies.filter(Utils.cookieLegal))] : [], })).filter((g) => g.threads.length && g.cookies.length); } function mergeThreadCookieWhitelistGroups(groups) { const threadOrder = []; const threadToState = new Map(); const mergeEvents = []; groups.forEach((group, idx) => { const desc = typeof group.desc === 'string' && isValidDesc(group.desc.trim()) ? group.desc.trim() : ''; const cookies = [...new Set((group.cookies || []).filter(Utils.cookieLegal))]; const threads = [...new Set((group.threads || []).filter(isValidThreadId))]; threads.forEach((threadId) => { if (!threadToState.has(threadId)) { threadToState.set(threadId, { desc, cookies: new Set(), firstIndex: typeof group.rowIndex === 'number' ? group.rowIndex : idx }); threadOrder.push(threadId); } else { mergeEvents.push({ threadId, rowIndex: typeof group.rowIndex === 'number' ? group.rowIndex : idx, desc, cookies, }); } cookies.forEach((cookie) => threadToState.get(threadId).cookies.add(cookie)); }); }); const grouped = new Map(); const mergedGroups = []; threadOrder.forEach((threadId) => { const state = threadToState.get(threadId); const cookies = Array.from(state.cookies); const desc = state.desc || ''; const key = `${desc}\u0002${cookies.slice().sort().join('\u0001')}`; if (!grouped.has(key)) { const group = { desc, threads: [], cookies }; grouped.set(key, group); mergedGroups.push(group); } grouped.get(key).threads.push(threadId); }); return { groups: mergedGroups, mergeEvents, }; } // 兼容旧版本 blockedCookies 值到“组结构” function normalizeBlockedGroups(val) { if (!val) return []; if (typeof val === 'string') { const tokens = Utils.strToList(val); return tokens.map(t=>{ const {desc, list} = parseDescAndListByLastColon(t); const id = list[0] || ''; return id && Utils.cookieLegal(id) ? { desc, cookies:[id] } : null; }).filter(Boolean); } if (Array.isArray(val)) { if (val.length && 'cookies' in val[0]) { return val.map(g=>({ desc: g.desc || '', cookies: Array.isArray(g.cookies) ? g.cookies.filter(Utils.cookieLegal) : [] })).filter(g=>g.cookies.length); } if (val.length && 'cookie' in val[0]) { const map = new Map(); val.forEach(({cookie, desc})=>{ if (!Utils.cookieLegal(cookie)) return; const key = desc || ''; if (!map.has(key)) map.set(key, []); map.get(key).push(cookie); }); return [...map.entries()].map(([desc, cookies])=>({desc, cookies})); } } return []; } function escapeBlockedKeywordInputToken(keyword) { return String(keyword || '').trim().replace(/([\\,,])/g, '\\$1'); } function joinBlockedKeywordInputTokens(keywords) { return keywords.map(escapeBlockedKeywordInputToken).filter(Boolean).join(','); } function normalizeBlockedKeywordGroupValue(group) { if (typeof group === 'string') return group.trim(); if (Array.isArray(group)) return joinBlockedKeywordInputTokens(group); if (!group || typeof group !== 'object') return ''; if (typeof group.value === 'string') return group.value.trim(); if (typeof group.text === 'string') return group.text.trim(); if (typeof group.keywords === 'string') return group.keywords.trim(); if (Array.isArray(group.keywords)) return joinBlockedKeywordInputTokens(group.keywords); return ''; } function isBlockedKeywordRegexGroup(group) { return !!(group && typeof group === 'object' && (group.regex === true || group.isRegex === true || group.useRegex === true)); } function normalizeBlockedKeywordGroups(val) { if (!val) return []; if (typeof val === 'string') { const value = normalizeBlockedKeywordGroupValue(val); return Utils.strToList(value).length ? [{ value, regex: false }] : []; } if (!Array.isArray(val)) return []; return val.map((group) => { const value = normalizeBlockedKeywordGroupValue(group); const regex = isBlockedKeywordRegexGroup(group); return { value, regex }; }).filter((group) => { if (!group.value) return false; // 正则组:整段 pattern 原样保留;普通组:仍按逗号切词后判断是否为空 return group.regex ? true : Utils.strToList(group.value).length > 0; }); } // 仅扁平化“普通关键词组”(正则组不参与逗号切分) function flattenBlockedKeywords(groups) { return [...new Set( normalizeBlockedKeywordGroups(groups) .filter((group) => !group.regex) .flatMap((group) => Utils.strToList(group.value)) )]; } // 编译屏蔽关键词正则;非法 pattern 返回 null(匹配时跳过,保存时再提示) function compileBlockedKeywordRegex(pattern) { const source = String(pattern || '').trim(); if (!source) return null; try { // 正文 textContent 常带前导/段落换行;默认 ignoreCase + dotAll,让 .* 可跨行 // 不用 m:^/$ 仍表示整段正文起止,而不是每一行 return new RegExp(source, 'is'); } catch (e) { try { // 极旧环境无 s 标志时回退到仅 i return new RegExp(source, 'i'); } catch (e2) { return null; } } } // 7/8/9 位纯数字关键词:除正文子串外,也按串号/回复号做 ID 匹配 function isThreadIdKeyword(keyword) { return /^\d{7,9}$/.test(String(keyword || '').trim()); } // 兼容旧名 function isEightDigitKeyword(keyword) { return isThreadIdKeyword(keyword); } function normalizeMarkedGroups(val) { if (!val) return []; if (typeof val === 'string') { const tokens = Utils.strToList(val); return tokens.map(t => { const { desc, list } = parseDescAndListByLastColon(t); const cookies = list.filter(Utils.cookieLegal); return cookies.length ? { desc, color: '', colorAt: 0, cookies } : null; }).filter(Boolean); } if (Array.isArray(val)) { if (val.length && 'cookies' in val[0]) { return val.map(g => ({ desc: typeof g.desc === 'string' && isValidDesc(g.desc.trim()) ? g.desc.trim() : '', color: normalizeHexColor(g.color), // colorAt:自定义配色的修改时间戳(0 = 旧版本数据/从未手动设置过配色) colorAt: Math.max(0, Number(g.colorAt) || 0), cookies: Array.isArray(g.cookies) ? [...new Set(g.cookies.filter(Utils.cookieLegal))] : [] })).filter(g => g.cookies.length); } if (val.length && 'cookie' in val[0]) { const map = new Map(); val.forEach(({ cookie, desc }) => { if (!Utils.cookieLegal(cookie)) return; const key = typeof desc === 'string' && isValidDesc(desc.trim()) ? desc.trim() : ''; if (!map.has(key)) map.set(key, []); map.get(key).push(cookie); }); return [...map.entries()].map(([desc, cookies]) => ({ desc, color: '', colorAt: 0, cookies: [...new Set(cookies)] })); } } return []; } // 配色合并规则(标记饼干跨端共用): // - 未手动设置过(color='' 且无时间戳)→ 视为“保持默认”,对方任何变更都覆盖 // - 仅一方手动设置 → 该方生效 // - 双方都手动设置 → colorAt 新者胜;同时间/都无时间戳(旧版本数据)→ 本地优先 // - 清除配色(color='' 但带 colorAt)视为一次手动变更,可覆盖更旧的配色 // 返回 null 表示该对象不是标记分组(如屏蔽饼干),不应注入 color 字段 function resolveMarkedGroupColorState(localGroup, remoteGroup) { const lHas = localGroup && typeof localGroup === 'object' && 'color' in localGroup; const rHas = remoteGroup && typeof remoteGroup === 'object' && 'color' in remoteGroup; if (!lHas && !rHas) return null; const lc = normalizeHexColor(localGroup && localGroup.color); const rc = normalizeHexColor(remoteGroup && remoteGroup.color); const la = Math.max(0, Number(localGroup && localGroup.colorAt) || 0); const ra = Math.max(0, Number(remoteGroup && remoteGroup.colorAt) || 0); const lManual = !!lc || la > 0; const rManual = !!rc || ra > 0; if (!lManual && !rManual) return { color: '', colorAt: 0 }; if (!lManual) return { color: rc, colorAt: ra }; if (!rManual) return { color: lc, colorAt: la }; return ra > la ? { color: rc, colorAt: ra } : { color: lc, colorAt: la }; } // 标记:支持同一饼干命中多个分组时,title 展示多行备注,颜色取首匹配组 function extractCookieIdFromUidElement(el, fallbackText) { const datasetId = el && el.dataset ? el.dataset.xdexCookieId : ''; if (datasetId) return datasetId; const font = el && el.querySelector ? el.querySelector('font') : null; if (font) { for (const node of font.childNodes) { if (node.nodeType === Node.TEXT_NODE) { const value = String(node.nodeValue || '').trim(); if (value) return value; } } } const value = (String(fallbackText || '').split(':')[1] || fallbackText || '').trim(); const match = String(value).match(/[A-Za-z0-9]{3,8}/); return match ? match[0] : value; } function wrapCookieMarkTargetPreserveHtml(el, color) { if (!el) return; const existing = el.querySelector('.xdex-cookie-mark-target'); if (existing) { $(existing).css({ background: color, padding:'0 3px', borderRadius:'2px' }); return; } const target = document.createElement('span'); target.className = 'xdex-cookie-mark-target'; $(target).css({ background: color, padding:'0 3px', borderRadius:'2px' }); let passedColon = false; Array.from(el.childNodes).forEach(node => { if (!passedColon && node.nodeType === Node.TEXT_NODE) { const value = node.nodeValue || ''; const index = value.indexOf(':'); if (index !== -1) { node.nodeValue = value.slice(0, index + 1); const rest = value.slice(index + 1); if (rest) target.appendChild(document.createTextNode(rest)); passedColon = true; return; } } if (passedColon) target.appendChild(node); }); if (!target.childNodes.length) { while (el.firstChild) target.appendChild(el.firstChild); } el.appendChild(target); } function markAllCookies(groups, root) { const $scope = root ? $(root).find('span.h-threads-info-uid').add($(root).filter('span.h-threads-info-uid')) : $('span.h-threads-info-uid'); $scope.each(function(){ const $el = $(this); const rawText = $el.text(); const cid = extractCookieIdFromUidElement(this, rawText); // 先清除旧的标记样式 $el.find('.xdex-cookie-mark-target').contents().unwrap(); $el.css({ background: '', padding: '', borderRadius: '' }).removeAttr('title'); // 收集所有匹配的分组索引和备注 let firstMatchIdx = -1; const hits = []; for (let i=0; i<groups.length; i++){ const g = groups[i]; if (g.cookies.some(p=>Utils.cookieMatch(cid,p))) { if (firstMatchIdx === -1) firstMatchIdx = i; // 记录第一个匹配的分组索引 if (g.desc) hits.push(g.desc); // 只有有备注时才加入 hits } } // 如果没有匹配到任何分组,保持清除状态 if (firstMatchIdx === -1) return; // 根据第一个匹配的分组索引选择颜色 const color = getMarkedGroupEffectiveColor(groups[firstMatchIdx], firstMatchIdx); if (rawText.includes(':') && cid) { wrapCookieMarkTargetPreserveHtml(this, color); } else { $el.css({ background: color, padding:'0 3px', borderRadius:'2px' }); } // 只有当有备注时才设置 title if (hits.length > 0) { $el.attr('title', hits.join('\n')); } }); } function getFilterConfig(cfg) { if (cfg && typeof cfg === 'object') { const next = Object.assign({}, cfg); next.blockedCookies = normalizeBlockedGroups(next.blockedCookies); next.blockedKeywords = normalizeBlockedKeywordGroups(next.blockedKeywords); next.threadCookieWhitelistGroups = normalizeThreadCookieWhitelistGroups(next.threadCookieWhitelistGroups); return next; } try { const next = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); next.markedGroups = normalizeMarkedGroups(next.markedGroups); next.blockedCookies = normalizeBlockedGroups(next.blockedCookies); next.blockedKeywords = normalizeBlockedKeywordGroups(next.blockedKeywords); next.threadCookieWhitelistGroups = normalizeThreadCookieWhitelistGroups(next.threadCookieWhitelistGroups); return next; } catch (e) { return Object.assign({}, SettingPanel.defaults); } } function isUpdateCheckEnabled() { try { const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); return !!cfg.enableUpdateCheck; } catch (e) { return !!SettingPanel.defaults.enableUpdateCheck; } } function asFilterRoot(root) { if (!root) return $(document); return root.jquery ? root : $(root); } function withSelf($root, selector) { return $root.filter(selector).add($root.find(selector)); } function resetTag3FilterState(root) { const $root = asFilterRoot(root); // 恢复折叠状态 withSelf($root, '[data-xdex-filter-target="1"]').each((_, el) => { $(el) .show() .removeData('xdex-collapsed') .removeAttr('data-xdex-filter-target') .removeClass('xdex-generic-collapsed'); }); // 恢复隐藏状态 withSelf($root, '[data-xdex-filter-hidden="1"]').each((_, el) => { $(el).show().removeAttr('data-xdex-filter-hidden'); }); withSelf($root, '[data-xdex-filter-placeholder="1"]').remove(); } function decorateFilterPlaceholder($ph, $el, placeholderClass, expandedFlex = '0 0 auto') { $ph.attr('data-xdex-filter-placeholder', '1').addClass(placeholderClass); $el.attr('data-xdex-filter-target', '1'); if ($ph && $el.hasClass('h-threads-item-reply-main')) { const $row = $el.closest('.h-threads-item-reply'); const $icon = $row.find('.h-threads-item-reply-icon').first(); if ($row.length) { $row.css({ display: 'flex', alignItems: 'flex-start' }); } if ($icon.length) { $icon.css({ flex: '0 0 3em', textAlign: 'center' }); } const applyWidth = () => { const txt = ($ph.text() || '').trim(); if (txt === '点击折叠') { $ph.css({ flex: '0 0 auto', maxWidth: '8em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', marginRight: '0.5em' }); } else { $ph.css({ flex: expandedFlex, maxWidth: 'none', whiteSpace: 'normal', overflow: 'visible', textOverflow: 'clip', marginRight: '0' }); } }; applyWidth(); $ph.off('click.xdex-filter-width').on('click.xdex-filter-width', () => { setTimeout(applyWidth, 0); }); } } function getThreadIdForElement($el) { const $index = $el.closest('.h-threads-item-index[data-threads-id]'); const indexTid = ($index.attr('data-threads-id') || '').trim(); if (isValidThreadId(indexTid)) return indexTid; const pathTid = PageType.getThreadId(false); if (pathTid) return pathTid.slice(0, 8); const href = $el.closest('.h-threads-item-index, .h-threads-item, .h-threads-item-reply, .h-threads-item-reply-main') .find('.h-threads-info-id[href*="/t/"]').first().attr('href') || ''; const hrefMatch = href.match(/\/t\/(\d{6,8})/); return hrefMatch ? hrefMatch[1].slice(0, 8) : ''; } function addFilterId(ids, value) { // 抓取串号/回复号:支持 7/8/9 位(跳过系统 9999999/99999999) const matches = String(value || '').match(/\d{7,9}/g) || []; matches.forEach((id) => { if (id === '9999999' || id === '99999999') return; if (!ids.includes(id)) ids.push(id); }); } function getFilterIdsForElement($el) { const ids = []; addFilterId(ids, $el.attr('data-threads-id')); addFilterId(ids, $el.closest('[data-threads-id]').attr('data-threads-id')); addFilterId(ids, $el.find('.h-threads-info-id').first().text()); addFilterId(ids, $el.closest('.h-threads-item-index, .h-threads-item, .h-threads-item-reply, .h-threads-item-reply-main').find('.h-threads-info-id').first().text()); const href = $el.closest('.h-threads-item-index, .h-threads-item, .h-threads-item-reply, .h-threads-item-reply-main').find('.h-threads-info-id[href]').first().attr('href') || ''; addFilterId(ids, href); return ids; } // ---- 临时解除屏蔽(不修改设置)---- // 触发方式:链接带 ?xdex_unblock=1,或本会话内 sessionStorage 有该串的豁免标记。 // 只豁免「串号等于当前页串号」的关键词(正文子串 + ID 匹配两条路径), // 其它关键词与正则组不豁免;只看饼干白名单逻辑不受影响。 // sessionStorage 标记由屏蔽占位条上的「临时显示」按钮写入(见 decorateFilterPlaceholder 附近), // 关闭浏览器自动失效。 function getUnblockExemptThreadId() { let parsed = null; try { parsed = parseThreadHistoryUrl(location.href); } catch (e) { parsed = null; } if (!parsed) return null; let viaUrl = false; try { viaUrl = new URL(location.href).searchParams.has('xdex_unblock'); } catch (e) { viaUrl = false; } let viaSession = false; try { viaSession = !!(sessionStorage.getItem('xdex_unblock:' + parsed.threadId)); } catch (e) { viaSession = false; } return (viaUrl || viaSession) ? parsed.threadId : null; } // 判断某串号是否被「串号关键词」屏蔽(临时显示提示条的显示条件) function isThreadIdBlockedByKeyword(threadId, blockedKeywords) { if (!threadId) return false; const normalized = normalizeBlockedKeywordGroups(blockedKeywords); return flattenBlockedKeywords(normalized).some((keyword) => ( isThreadIdKeyword(keyword) && String(keyword || '').trim() === String(threadId) )); } // 屏蔽提示条:仅当当前页串号被串号关键词屏蔽且未豁免时显示。 // 「临时显示」写 sessionStorage 后刷新(本会话内生效,关浏览器失效); // 「×」仅收起本次提示(sessionStorage 记忆,不改变屏蔽行为)。 function maybeShowUnblockBanner(threadId, cfg) { if (!threadId) return; if (getUnblockExemptThreadId()) return; let dismissed = false; try { dismissed = sessionStorage.getItem('xdex_unblock_dismissed:' + threadId) === '1'; } catch (e) { dismissed = false; } if (dismissed) return; if (!isThreadIdBlockedByKeyword(threadId, (cfg && cfg.blockedKeywords) || [])) return; if (document.getElementById('xdex-unblock-banner')) return; const bar = document.createElement('div'); bar.id = 'xdex-unblock-banner'; bar.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:64px;z-index:99998;' + 'background:#333;color:#ddd;padding:8px 14px;border-radius:6px;font-size:14px;' + 'box-shadow:0 2px 8px rgba(0,0,0,.4);display:flex;gap:10px;align-items:center;'; const label = document.createElement('span'); label.textContent = '本串已被关键词屏蔽'; const showBtn = document.createElement('button'); showBtn.textContent = '临时显示'; showBtn.style.cssText = 'background:#00FFCC;color:#00332b;border:0;border-radius:4px;padding:3px 10px;cursor:pointer;font-size:14px;font-weight:600;'; showBtn.onclick = () => { try { sessionStorage.setItem('xdex_unblock:' + threadId, '1'); } catch (e) {} location.reload(); }; const closeBtn = document.createElement('button'); closeBtn.textContent = '×'; closeBtn.title = '本次不显示(屏蔽仍生效)'; closeBtn.style.cssText = 'background:transparent;color:#888;border:0;cursor:pointer;font-size:14px;line-height:1;padding:0 2px;'; closeBtn.onclick = () => { try { sessionStorage.setItem('xdex_unblock_dismissed:' + threadId, '1'); } catch (e) {} bar.remove(); }; bar.appendChild(label); bar.appendChild(showBtn); bar.appendChild(closeBtn); (document.body || document.documentElement).appendChild(bar); } function findBlockedKeywordHit(text, groups, $el) { const normalizedGroups = normalizeBlockedKeywordGroups(groups); const body = String(text || ''); // 1) 正则组:整段 pattern 直接匹配正文(不走逗号切分,不做 7/8/9 位 ID 特判) for (const group of normalizedGroups) { if (!group.regex) continue; const re = compileBlockedKeywordRegex(group.value); if (!re) continue; if (re.test(body)) return group.value; } // 2) 普通组:逗号切分后正文子串 + 7/8/9 位数字 ID 匹配 // 临时豁免:当前页串号被「串号关键词」屏蔽且用户通过 ?xdex_unblock=1 / 会话标记打开时, // 该关键词从列表剔除(正文子串与 ID 两条路径同时失效),其余关键词照常生效 const exemptThreadId = getUnblockExemptThreadId(); const keywords = flattenBlockedKeywords(normalizedGroups) .filter((keyword) => !(exemptThreadId && isThreadIdKeyword(keyword) && String(keyword || '').trim() === exemptThreadId)); const textHit = Utils.firstHit(body, keywords); if (textHit) return textHit; const ids = getFilterIdsForElement($el); return keywords.find((keyword) => isThreadIdKeyword(keyword) && ids.includes(String(keyword || '').trim())) || null; } function getThreadWhitelistGroup(threadId, groups) { if (!threadId || !groups.length) return null; for (const group of groups) { if ((group.threads || []).includes(threadId)) { return group; } } return null; } function isSystemReplyElement($el) { if ($el.closest('.h-threads-item-reply[data-threads-id="9999999"]').length) return true; const replyIdText = ($el.find('.h-threads-info-id').first().text() || '').trim(); return replyIdText === 'No.9999999'; } function isThreadPageForPOAnnotation() { return /\/t\/\d{6,8}/.test(location.pathname); } function isLiveDocumentRoot(root) { if (!root || root === document) return true; try { return !!(root.ownerDocument === document && document.documentElement && document.documentElement.contains(root)); } catch (e) { return false; } } function refreshFilterDisplay(cfg, root) { return startupPerfDebug.measure('refreshFilterDisplay', () => { if (!isLiveDocumentRoot(root)) { applyFilters(cfg, root || document); return; } if (typeof refreshPOAnnotationMode === 'function') { refreshPOAnnotationMode(root || document, cfg); } else if (typeof applyFilters === 'function') { applyFilters(cfg, root || document); } }, () => startupPerfDebug.summarizeRoot(root || document)); } function getThreadCookieWhitelistDisplayMode(cfg) { return (cfg && cfg.threadCookieWhitelistDisplayMode) || 'fold'; } function getPOAnnotationSideDisplayMode(cfg) { return (cfg && cfg.poAnnotationSideDisplayMode) || 'collapse'; } function isPOAnnotationActive(cfg) { return getThreadCookieWhitelistDisplayMode(cfg) === 'column' && isThreadPageForPOAnnotation(); } function getCookieIdFromReplyMain($reply) { return ($reply.find('.h-threads-info-uid').first().text().split(':')[1] || '').trim(); } function detectRuntimePOGroups(root, cfg) { const baseCfg = getFilterConfig(cfg); if (!isPOAnnotationActive(baseCfg)) return null; const threadId = getThreadIdForElement(asFilterRoot(root || document)); if (!threadId) return null; const cookies = []; withSelf(asFilterRoot(root || document), '.h-threads-item-reply-main').each((_, el) => { const $reply = $(el); if (!$reply.find('span.uk-text-primary.uk-text-small').length) return; const cid = getCookieIdFromReplyMain($reply); if (Utils.cookieLegal(cid) && !cookies.includes(cid)) cookies.push(cid); }); return cookies.length ? { desc: 'PO', threads: [String(threadId).slice(0, 8)], cookies, isAuto: true } : null; } function buildEffectiveWhitelistGroups(manualGroups, autoPOGroup) { const groups = normalizeThreadCookieWhitelistGroups(manualGroups || []).map(g => ({ desc: g.desc || '', threads: [...(g.threads || [])], cookies: [...(g.cookies || [])] })); if (autoPOGroup && autoPOGroup.threads?.length && autoPOGroup.cookies?.length) { groups.push({ desc: autoPOGroup.desc || 'PO', threads: [...autoPOGroup.threads], cookies: [...autoPOGroup.cookies] }); } return mergeThreadCookieWhitelistGroups(groups).groups; } function buildPOAnnotationLiveConfig(cfg, root) { const liveCfg = getFilterConfig(cfg); if (!isPOAnnotationActive(liveCfg)) return liveCfg; const autoPOGroup = detectRuntimePOGroups(root, liveCfg); liveCfg.threadCookieWhitelistGroups = buildEffectiveWhitelistGroups(liveCfg.threadCookieWhitelistGroups || [], autoPOGroup); return liveCfg; } function applyFilters(cfg) { const root = arguments.length > 1 ? arguments[1] : undefined; return startupPerfDebug.measure('applyFilters', () => { const $root = asFilterRoot(root); cfg = getFilterConfig(cfg); if (applyFilters._running) return; // 防重入 applyFilters._running = true; try { resetTag3FilterState($root); // 标记 markAllCookies(cfg.markedGroups||[], $root); // 屏蔽(按组,匹配到则折叠,文案含备注) const blkG = (cfg.blockedCookies||[]); const blkKGroups = normalizeBlockedKeywordGroups(cfg.blockedKeywords); const whitelistGroups = normalizeThreadCookieWhitelistGroups(cfg.threadCookieWhitelistGroups || []); const whitelistDisplayMode = getThreadCookieWhitelistDisplayMode(cfg); const poAnnotationMode = isPOAnnotationActive(cfg); const displayMode = cfg.blockDisplayMode || 'fold'; const checkBlocked = $el => { if ($el.closest('.h-preview-box').length) return; if (isSystemReplyElement($el)) return; const cid = ($el.find('.h-threads-info-uid').first().text().split(':')[1]||'').trim(); const txt = $el.find('.h-threads-content').first().text(); if (cid && blkG.length) { let matchedCookie = null, matchedDesc = ''; for (let i=0; i<blkG.length && !matchedCookie; i++){ const g = blkG[i]; const hit = g.cookies.find(p=>Utils.cookieMatch(cid,p)); if (hit) { matchedCookie = hit; matchedDesc = g.desc || ''; } } if (matchedCookie) { if (displayMode === 'hide') { const _c = $el.closest('.h-threads-item-reply'); const $target = _c.length ? _c : $el; // 板块页主串:隐藏下方的 <hr> 分隔线 if ($target.hasClass('h-threads-item-index')) { const $hr = $target.next('hr'); if ($hr.length) $hr.hide().attr('data-xdex-filter-hidden', '1'); } $target.hide().attr('data-xdex-filter-hidden', '1'); } else { const label = matchedDesc ? `${matchedCookie}:${matchedDesc}` : matchedCookie; const $ph = Utils.collapse($el, `饼干屏蔽『${label}』`); if ($ph) decorateFilterPlaceholder($ph, $el, 'xdex-placeholder-blocked', '0 0 auto'); } return; } } const kw = findBlockedKeywordHit(txt, blkKGroups, $el); if (kw) { if (displayMode === 'hide') { const _c = $el.closest('.h-threads-item-reply'); const $target = _c.length ? _c : $el; // 板块页主串:隐藏下方的 <hr> 分隔线 if ($target.hasClass('h-threads-item-index')) { const $hr = $target.next('hr'); if ($hr.length) $hr.hide().attr('data-xdex-filter-hidden', '1'); } $target.hide().attr('data-xdex-filter-hidden', '1'); } else { const $ph = Utils.collapse($el, `关键词屏蔽『${kw}』`); if ($ph) decorateFilterPlaceholder($ph, $el, 'xdex-placeholder-blocked', '0 0 auto'); } } }; const checkWhitelistReply = ($reply, whitelistGroup) => { if ($reply.closest('.h-preview-box').length) return; if (isSystemReplyElement($reply)) return; const cid = ($reply.find('.h-threads-info-uid').first().text().split(':')[1] || '').trim(); if (!cid) return; const whitelistCookies = whitelistGroup?.cookies || []; if (whitelistCookies.some((pattern) => Utils.cookieMatch(cid, pattern))) { return; } if (poAnnotationMode) return; if (whitelistDisplayMode === 'hide') { const _c = $reply.closest('.h-threads-item-reply'); const $target = _c.length ? _c : $reply; $target.hide().attr('data-xdex-filter-hidden', '1'); return; } // 只看饼干始终保持折叠,不受 displayMode 影响 const prefix = whitelistGroup && whitelistGroup.desc ? `『${whitelistGroup.desc}』` : ''; const $ph = Utils.collapse($reply, `${prefix}只看饼干『${whitelistCookies.join(',')}』`); if ($ph) decorateFilterPlaceholder($ph, $reply, 'xdex-placeholder-whitelist', '0 0 auto'); }; if(/\/t\/\d{6,8}/.test(location.pathname)){ const currentThreadId = getThreadIdForElement($root); const whitelistGroup = getThreadWhitelistGroup(currentThreadId, whitelistGroups); withSelf($root, '.h-threads-item-reply-main').each((_,el)=>{ const $reply = $(el); if (whitelistGroup) { checkWhitelistReply($reply, whitelistGroup); } else { checkBlocked($reply); } }); } else { const $threads = withSelf($root, '.h-threads-item-index'); if ($threads.length) { $threads.each((_,el)=>{ const $th=$(el); const threadId = getThreadIdForElement($th); const whitelistGroup = getThreadWhitelistGroup(threadId, whitelistGroups); if (!whitelistGroup) { checkBlocked($th); } $th.find('.h-threads-item-reply-main').each((_,s)=>{ const $reply = $(s); if (whitelistGroup) { checkWhitelistReply($reply, whitelistGroup); } else { checkBlocked($reply); } }); }); } else { withSelf($root, '.h-threads-item-reply-main').each((_, el) => { const $reply = $(el); const threadId = getThreadIdForElement($reply); const whitelistGroup = getThreadWhitelistGroup(threadId, whitelistGroups); if (whitelistGroup) { checkWhitelistReply($reply, whitelistGroup); } else { checkBlocked($reply); } }); } } // 串号关键词屏蔽当前页时给「临时显示」入口(不改设置;详见 getUnblockExemptThreadId 注释) try { const currentThread = parseThreadHistoryUrl(location.href); if (currentThread && !getUnblockExemptThreadId()) maybeShowUnblockBanner(currentThread.threadId, cfg); } catch (e) {} } finally { applyFilters._running = false; } }, () => startupPerfDebug.summarizeRoot(root || document)); } function resetPOAnnotationLayout(root) { const scope = root || document; const anchors = Array.from(scope.querySelectorAll ? scope.querySelectorAll('[data-xdex-po-annotation-anchor="1"]') : []); anchors.forEach(anchor => { const key = anchor.getAttribute('data-xdex-po-annotation-key'); const repliesRoot = anchor.closest('.h-threads-item-replies') || document; const moved = key ? repliesRoot.querySelector(`.h-threads-item-reply[data-xdex-po-annotation-moved="${key}"]`) : null; if (moved && anchor.parentNode) { anchor.parentNode.insertBefore(moved, anchor); moved.removeAttribute('data-xdex-po-annotation-moved'); moved.removeAttribute('data-xdex-po-annotation-role'); } anchor.remove(); }); const wrappers = Array.from(scope.querySelectorAll ? scope.querySelectorAll('[data-xdex-po-annotation-wrapper="1"]') : []); wrappers.forEach(wrapper => { const repliesRoot = wrapper.closest('.h-threads-item-replies'); const mainReplies = Array.from(wrapper.querySelectorAll('.xdex-po-annotation-main > .h-threads-item-reply')); mainReplies.forEach(reply => { if (repliesRoot) { reply.removeAttribute('data-xdex-po-annotation-highlight'); reply.removeAttribute('data-xdex-po-annotation-role'); repliesRoot.insertBefore(reply, wrapper); } }); const sideReplies = Array.from(wrapper.querySelectorAll('.h-threads-item-reply[data-xdex-po-annotation-moved]')); sideReplies.forEach(reply => { if (repliesRoot) { reply.removeAttribute('data-xdex-po-annotation-moved'); reply.removeAttribute('data-xdex-po-annotation-role'); repliesRoot.insertBefore(reply, wrapper); } }); wrapper.remove(); }); Array.from(scope.querySelectorAll ? scope.querySelectorAll('.h-threads-item-reply[data-xdex-po-annotation-moved]') : []).forEach(reply => { const repliesRoot = reply.closest('.h-threads-item-replies'); if (repliesRoot && reply.parentNode !== repliesRoot) { const fallbackWrapper = repliesRoot.querySelector('[data-xdex-po-annotation-wrapper="1"]'); if (fallbackWrapper && fallbackWrapper.parentNode === repliesRoot) { repliesRoot.insertBefore(reply, fallbackWrapper); } else { repliesRoot.appendChild(reply); } } reply.removeAttribute('data-xdex-po-annotation-moved'); reply.removeAttribute('data-xdex-po-annotation-role'); }); Array.from(scope.querySelectorAll ? scope.querySelectorAll('[data-xdex-po-annotation-anchor="1"], [data-xdex-po-annotation-wrapper="1"]') : []).forEach(el => el.remove()); Array.from(scope.querySelectorAll ? scope.querySelectorAll('[data-xdex-po-annotation-audience], [data-xdex-po-annotation-highlight]') : []).forEach(el => { el.removeAttribute('data-xdex-po-annotation-audience'); el.removeAttribute('data-xdex-po-annotation-highlight'); el.removeAttribute('data-xdex-po-annotation-role'); }); } function ensurePOAnnotationStyle() { if (document.getElementById('xdex-po-annotation-style')) return; const style = document.createElement('style'); style.id = 'xdex-po-annotation-style'; style.textContent = ` .xdex-po-annotation-group{display:grid;grid-template-columns:minmax(0,1fr) clamp(220px,31vw,380px);gap:10px;align-items:start;margin:6px 0;overflow:visible;} .xdex-po-annotation-main{min-width:0;position:relative;z-index:2;overflow:visible;} .xdex-po-annotation-side{min-width:0;border-left:2px solid #d2b59e;padding-left:8px;opacity:.92;position:relative;z-index:1;overflow:visible;} .xdex-po-annotation-side-title{font-size:12px;color:#886b58;margin:0 0 4px;cursor:pointer;user-select:none;} .xdex-po-annotation-side-body{min-width:0;overflow:visible;} .xdex-po-annotation-side[data-side-display-mode="collapse"] .xdex-po-annotation-side-body{overflow:auto;} .xdex-po-annotation-side .h-threads-item-reply{font-size:12px;margin-bottom:6px;} .xdex-po-annotation-main .h-threads-item-reply{position:relative;z-index:2;overflow:visible;} .xdex-po-annotation-main .h-threads-img-box.h-active{position:relative;z-index:20;overflow:visible !important;} .xdex-po-annotation-main .h-threads-img-box.h-active .h-threads-img-a{position:relative;z-index:21;overflow:visible !important;} .xdex-po-annotation-main .h-threads-img-box.h-active .h-threads-img{position:relative;z-index:22;} .xdex-po-annotation-side .h-threads-item-reply-main,.xdex-po-annotation-side .h-threads-content,.xdex-po-annotation-side .h-threads-img-box,.xdex-po-annotation-side .h-threads-img-a,.xdex-po-annotation-side .h-threads-img,.xdex-po-annotation-side img{max-width:100% !important;height:auto !important;box-sizing:border-box;} .xdex-po-annotation-side .h-threads-item-reply-main{width:100% !important;} .xdex-po-annotation-side .h-threads-img-box{width:100% !important;max-width:100% !important;} .xdex-po-annotation-side .h-threads-img-a{display:block;max-width:100% !important;overflow:hidden;} .xdex-po-annotation-side .h-threads-img{max-width:100% !important;height:auto !important;} .xdex-po-annotation-side .h-threads-img-box.h-active{max-width:100% !important;overflow:hidden !important;} .xdex-po-annotation-side .h-threads-img-box .h-threads-img-tool-btn{font-size:11px;white-space:nowrap;} @media (max-width: 860px){.xdex-po-annotation-group{display:block}.xdex-po-annotation-side{border-left:0;border-top:1px dashed #d2b59e;margin-top:6px;padding:6px 0 0;}} `; document.head.appendChild(style); } function openPOAnnotationReplyQuote(replyEl) { if (!replyEl) return false; const tid = (replyEl.getAttribute('data-threads-id') || '').trim(); if (!/^\d{1,}$/.test(tid) || tid === '9999999') return false; try { if (typeof window.__xdexOpenQuoteByTid !== 'function' && typeof enableQuotePreview === 'function') { enableQuotePreview(); } if (typeof window.__xdexOpenQuoteByTid !== 'function') return false; const ret = window.__xdexOpenQuoteByTid(tid, { fromPOImage: true, currentThreadId: (window.__xdexQuoteRefMark && window.__xdexQuoteRefMark.getContextThreadId(replyEl)) || '' }); if (ret && typeof ret.then === 'function') { ret.catch(() => {}); } return true; } catch (e) { return false; } } function bindPOAnnotationSideImageQuotePreview() { if (document.documentElement.dataset.xdexPoImageQuoteBound === '1') return; document.documentElement.dataset.xdexPoImageQuoteBound = '1'; document.addEventListener('click', (e) => { if (e.defaultPrevented) return; if (typeof e.button === 'number' && e.button !== 0) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; const trigger = e.target.closest('.xdex-po-annotation-side .h-threads-img-a'); if (!trigger) return; const replyEl = trigger.closest('.h-threads-item-reply'); if (!replyEl) return; const opened = openPOAnnotationReplyQuote(replyEl); if (!opened) return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }, true); } function updatePOAnnotationSideState(group, expanded) { if (!group) return; const side = group.querySelector('.xdex-po-annotation-side'); const title = group.querySelector('.xdex-po-annotation-side-title'); const body = group.querySelector('.xdex-po-annotation-side-body'); const main = group.querySelector('.xdex-po-annotation-main'); if (!side || !title || !body || !main) return; side.setAttribute('data-side-expanded', expanded ? '1' : '0'); const displayMode = expanded ? 'expand' : 'collapse'; side.setAttribute('data-side-display-mode', displayMode); title.setAttribute('data-side-expanded', expanded ? '1' : '0'); if (expanded) { body.style.maxHeight = 'none'; body.style.overflowY = 'visible'; } else { const mainHeight = Math.max(main.offsetHeight || 0, 88); body.style.maxHeight = `${mainHeight}px`; body.style.overflowY = body.scrollHeight > mainHeight ? 'auto' : 'visible'; } } function bindPOAnnotationSideToggle(group, defaultMode) { if (!group || group.getAttribute('data-xdex-po-side-toggle-bound') === '1') return; group.setAttribute('data-xdex-po-side-toggle-bound', '1'); const title = group.querySelector('.xdex-po-annotation-side-title'); if (!title) return; title.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const side = group.querySelector('.xdex-po-annotation-side'); const expanded = !(side && side.getAttribute('data-side-expanded') === '1'); updatePOAnnotationSideState(group, expanded); }); updatePOAnnotationSideState(group, defaultMode === 'expand'); } function isPOAnnotationHighlightReply(reply, whitelistGroup) { if (!reply || !whitelistGroup) return false; if (reply.getAttribute('data-threads-id') === '9999999') return false; const main = reply.querySelector('.h-threads-item-reply-main'); if (!main) return false; const cid = ($(main).find('.h-threads-info-uid').first().text().split(':')[1] || '').trim(); return !!cid && (whitelistGroup.cookies || []).some(pattern => Utils.cookieMatch(cid, pattern)); } function applyPOAnnotationLayout(root, liveCfg) { if (!isPOAnnotationActive(liveCfg)) return; if (window.innerWidth && window.innerWidth < 860) return; const rootNode = root || document; const threadId = getThreadIdForElement(asFilterRoot(rootNode)); const whitelistGroup = getThreadWhitelistGroup(threadId, normalizeThreadCookieWhitelistGroups(liveCfg.threadCookieWhitelistGroups || [])); if (!whitelistGroup) return; ensurePOAnnotationStyle(); bindPOAnnotationSideImageQuotePreview(); const sideDisplayMode = getPOAnnotationSideDisplayMode(liveCfg); const containers = []; if (rootNode.matches && rootNode.matches('.h-threads-item-replies')) containers.push(rootNode); if (rootNode.querySelectorAll) rootNode.querySelectorAll('.h-threads-item-replies').forEach(el => containers.push(el)); const uniqueContainers = [...new Set(containers)]; uniqueContainers.forEach(repliesRoot => { const replies = Array.from(repliesRoot.children).filter(el => el.classList && el.classList.contains('h-threads-item-reply')); const highlights = replies.filter(reply => isPOAnnotationHighlightReply(reply, whitelistGroup)); if (highlights.length < 2) return; let currentGroup = null; let movedCount = 0; let continuationGroup = null; const createGroup = (reply, sideTitle, continuationText = '') => { const group = document.createElement('div'); group.className = 'xdex-po-annotation-group'; group.setAttribute('data-xdex-po-annotation-wrapper', '1'); const main = document.createElement('div'); main.className = 'xdex-po-annotation-main'; const side = document.createElement('div'); side.className = 'xdex-po-annotation-side'; side.setAttribute('data-xdex-po-annotation-side', '1'); side.setAttribute('data-side-display-mode', sideDisplayMode); const title = document.createElement('div'); title.className = 'xdex-po-annotation-side-title'; title.textContent = sideTitle; side.appendChild(title); const sideBody = document.createElement('div'); sideBody.className = 'xdex-po-annotation-side-body'; side.appendChild(sideBody); if (continuationText) { const continuation = document.createElement('div'); continuation.className = 'xdex-po-annotation-side-title'; continuation.textContent = continuationText; main.appendChild(continuation); } repliesRoot.insertBefore(group, reply); group.appendChild(main); group.appendChild(side); bindPOAnnotationSideToggle(group, sideDisplayMode); return { wrapper: group, main, side }; }; replies.forEach(reply => { if (isPOAnnotationHighlightReply(reply, whitelistGroup)) { reply.setAttribute('data-xdex-po-annotation-highlight', '1'); const group = createGroup(reply, '批注'); group.main.appendChild(reply); currentGroup = group; return; } if (reply.getAttribute('data-threads-id') === '9999999') return; if (reply.querySelector('.h-threads-item-reply-main[data-xdex-filter-hidden="1"]')) return; if (!currentGroup) { continuationGroup = continuationGroup || createGroup(reply, '批注(续)', '上页续'); } const targetGroup = currentGroup || continuationGroup; if (!targetGroup) return; const key = `po-${Date.now()}-${Math.random().toString(36).slice(2)}`; const anchor = document.createElement('span'); anchor.setAttribute('data-xdex-po-annotation-anchor', '1'); anchor.setAttribute('data-xdex-po-annotation-key', key); anchor.style.display = 'none'; repliesRoot.insertBefore(anchor, reply); reply.setAttribute('data-xdex-po-annotation-moved', key); reply.setAttribute('data-xdex-po-annotation-role', 'side'); const sideBody = targetGroup.side.querySelector('.xdex-po-annotation-side-body') || targetGroup.side; sideBody.appendChild(reply); movedCount++; updatePOAnnotationSideState(targetGroup.wrapper, sideDisplayMode === 'expand'); }); Array.from(repliesRoot.querySelectorAll('[data-xdex-po-annotation-wrapper="1"]')).forEach(wrapper => { const hasSideReply = !!wrapper.querySelector('.xdex-po-annotation-side .h-threads-item-reply'); if (!hasSideReply) { const mainReplies = Array.from(wrapper.querySelectorAll('.xdex-po-annotation-main > .h-threads-item-reply')); mainReplies.forEach(reply => repliesRoot.insertBefore(reply, wrapper)); wrapper.remove(); } else { updatePOAnnotationSideState(wrapper, sideDisplayMode === 'expand'); } }); }); } function refreshPOAnnotationMode(root, cfg) { const targetRoot = root || document; resetPOAnnotationLayout(targetRoot); const liveCfg = buildPOAnnotationLiveConfig(cfg, targetRoot); applyFilters(liveCfg, targetRoot); try { applyPOAnnotationLayout(targetRoot, liveCfg); } catch (e) { try { resetPOAnnotationLayout(targetRoot); } catch (_) {} console.warn('PO annotation layout failed, fallback to filters-only', e); } } /* -------------------------------------------------- * tag 4. 外部图床显示 * -------------------------------------------------- */ const ExternalImagePreview = (function(){ let started = false; const PROCESSED_ATTRIBUTE = 'data-images-processed'; const DEFAULT_VISIBLE = 3; const LOAD_BATCH = 3; const imageUrlRegex = /(https?:\/\/[^\s'")\]}]+?\.(?:jpg|jpeg|png|gif|bmp|webp|svg)(?:\?[^\s'")\]}]*)?)(?=$|\s|['")\]}.,!?])/gi; function buttonCss() { return ` font-size: 12px; padding: 4px 10px; margin-left: 6px; color: #333; background: #fff; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; `; } function createImageItem(url, containerWidth) { const frag = document.createDocumentFragment(); const img = document.createElement('img'); img.src = url; img.style.cssText = ` display: block; margin: 8px auto 2px auto; border: 1px solid #ccc; border-radius: 3px; cursor: pointer; height: auto; `; const linkDiv = document.createElement('div'); linkDiv.style.cssText = ` font-size: 12px; color: #666; margin: 0 auto 10px auto; word-break: break-all; width: fit-content; max-width: 100%; `; const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.textContent = url; a.style.color = '#007bff'; a.addEventListener('click', (e) => e.stopPropagation()); linkDiv.appendChild(a); img.addEventListener('load', () => { const naturalW = img.naturalWidth || 0; if (naturalW > containerWidth) { img.style.width = Math.round(containerWidth * 0.75) + 'px'; img.dataset.state = 'large'; } else { img.style.width = naturalW + 'px'; img.dataset.state = 'small'; } }); img.addEventListener('error', () => { img.style.display = 'none'; linkDiv.style.display = 'none'; }); img.addEventListener('click', (e) => { e.stopPropagation(); const state = img.dataset.state; if (state === 'large') { window.open(url, '_blank'); } else if (state === 'small') { const naturalW = img.naturalWidth || 0; const targetW = Math.round(containerWidth * 0.75); if (!img.dataset.enlarged && targetW > naturalW) { img.style.width = targetW + 'px'; img.dataset.enlarged = 'true'; } else { window.open(url, '_blank'); } } }); frag.appendChild(img); frag.appendChild(linkDiv); return frag; } function appendImages(bodyEl, urls, containerWidth) { const frag = document.createDocumentFragment(); urls.forEach((url) => frag.appendChild(createImageItem(url, containerWidth))); bodyEl.appendChild(frag); } function setCollapsed(container, collapsed) { container.classList.toggle('collapsed', collapsed); container.dataset.collapsed = collapsed ? 'true' : 'false'; container.querySelectorAll('.iic-toggle-btn').forEach((btn) => { btn.textContent = collapsed ? '展开' : '收起'; }); } function injectContainer(afterDiv, imageUrls) { const total = imageUrls.length; if (total === 0) return; const container = document.createElement('div'); container.className = 'injected-image-container'; container.style.cssText = ` margin: 12px 0; border: 1px solid #ddd; border-radius: 6px; background-color: #f9f9f9; box-shadow: 0 1px 2px rgba(0,0,0,0.03); overflow: hidden; `; container.dataset.total = String(total); container.dataset.collapsed = 'false'; const header = document.createElement('div'); header.className = 'iic-header'; header.style.cssText = ` display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: #f2f2f2; border-bottom: 1px solid #e6e6e6; cursor: default; user-select: none; `; const title = document.createElement('span'); title.className = 'iic-title'; title.textContent = `图片预览(${total})`; title.style.cssText = `font-size: 13px; color: #333;`; const actions = document.createElement('div'); actions.className = 'iic-actions'; const moreTopBtn = document.createElement('button'); moreTopBtn.className = 'iic-more-btn-top'; moreTopBtn.style.cssText = buttonCss(); const toggleBtn = document.createElement('button'); toggleBtn.className = 'iic-toggle-btn'; toggleBtn.textContent = '收起'; toggleBtn.style.cssText = buttonCss(); toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); const next = container.dataset.collapsed !== 'true'; setCollapsed(container, next); }); actions.appendChild(moreTopBtn); actions.appendChild(toggleBtn); header.appendChild(title); header.appendChild(actions); const body = document.createElement('div'); body.className = 'iic-body'; body.style.cssText = ` padding: 12px 24px 10px 24px; overflow-x: auto; `; const footer = document.createElement('div'); footer.className = 'iic-footer'; footer.style.cssText = ` display: flex; align-items: center; justify-content: center; padding: 8px 12px 12px 12px; background: #f9f9f9; border-top: 1px solid #eee; `; const moreBottomBtn = document.createElement('button'); moreBottomBtn.className = 'iic-more-btn-bottom'; moreBottomBtn.style.cssText = buttonCss(); footer.appendChild(moreBottomBtn); const style = document.createElement('style'); style.textContent = ` .injected-image-container.collapsed .iic-body, .injected-image-container.collapsed .iic-footer { display: none; } `; container.appendChild(style); container.appendChild(header); container.appendChild(body); container.appendChild(footer); afterDiv.parentNode.insertBefore(container, afterDiv.nextSibling); const containerWidth = body.clientWidth || 600; const visibleUrls = imageUrls.slice(0, DEFAULT_VISIBLE); const queue = imageUrls.slice(DEFAULT_VISIBLE); appendImages(body, visibleUrls, containerWidth); function remainingCount() { return queue.length; } function updateMoreButtons() { const rem = remainingCount(); const label = rem > 0 ? `展开更多(剩余${rem},+${LOAD_BATCH})` : '已全部展开'; moreTopBtn.textContent = label; moreBottomBtn.textContent = label; const display = rem > 0 ? '' : 'none'; moreTopBtn.style.display = display; moreBottomBtn.style.display = display; } function loadMore(e) { e.stopPropagation(); if (queue.length === 0) return; const batch = queue.splice(0, LOAD_BATCH); appendImages(body, batch, containerWidth); updateMoreButtons(); } moreTopBtn.addEventListener('click', loadMore); moreBottomBtn.addEventListener('click', loadMore); updateMoreButtons(); container.addEventListener('click', (e) => { const target = e.target; if (target.closest('img, button, a, input, label, textarea, select')) return; if (container.dataset.collapsed !== 'true') setCollapsed(container, true); }); } function processDiv(div) { if (div.hasAttribute(PROCESSED_ATTRIBUTE)) return; // 如果是预览框且已经有图片,则直接标记为已处理并跳过 if (div.classList.contains('h-preview-box') && div.querySelector('img')) { div.setAttribute(PROCESSED_ATTRIBUTE, 'true'); return; } div.setAttribute(PROCESSED_ATTRIBUTE, 'true'); const textContent = div.textContent || div.innerText || ''; const matches = textContent.match(imageUrlRegex); if (!matches || matches.length === 0) return; const uniqueImageUrls = [...new Set(matches.map((u) => u.trim()))]; if (uniqueImageUrls.length === 0) return; injectContainer(div, uniqueImageUrls); } function findAndProcessDivs() { const divs = document.querySelectorAll( 'div.h-threads-content:not([' + PROCESSED_ATTRIBUTE + ']), ' + 'div.h-preview-box:not([' + PROCESSED_ATTRIBUTE + '])' ); divs.forEach(processDiv); } function observeChanges() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { mutation.addedNodes.forEach((node) => { if (node.nodeType !== 1) return; if (node.classList && node.classList.contains('injected-image-container')) return; if ( node.classList && (node.classList.contains('h-threads-content') || node.classList.contains('h-preview-box')) && !node.hasAttribute(PROCESSED_ATTRIBUTE) ) { processDiv(node); } const childDivs = node.querySelectorAll && node.querySelectorAll( 'div.h-threads-content:not([' + PROCESSED_ATTRIBUTE + ']), ' + 'div.h-preview-box:not([' + PROCESSED_ATTRIBUTE + '])' ); if (childDivs && childDivs.length > 0) { childDivs.forEach(processDiv); } }); } }); }); observer.observe(document.body, { childList: true, subtree: true }); } function init() { if (started) return; started = true; setTimeout(findAndProcessDivs, 100); observeChanges(); window.addEventListener('load', () => setTimeout(findAndProcessDivs, 500)); // 调试辅助 window.resetImageScript = function resetProcessedElements() { document.querySelectorAll('[' + PROCESSED_ATTRIBUTE + ']').forEach((el) => el.removeAttribute(PROCESSED_ATTRIBUTE)); document.querySelectorAll('.injected-image-container').forEach((c) => c.remove()); }; } return { init }; })(); /* -------------------------------------------------- * tag 5. 手动切换饼干 + 自动刷新饼干 * -------------------------------------------------- */ const abbreviateName = n => n.replace(/\s*-\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/, ''); const getCookiesList = () => GM_getValue('cookies', {}); const getCurrentCookie = () => GM_getValue('now-cookie', null); const LAST_USED_COOKIE_KEY = 'xdex-last-used-cookie'; const getLastUsedCookie = () => GM_getValue(LAST_USED_COOKIE_KEY, null); const isCookieListUnavailable = (list) => !list || Object.keys(list).length === 0; const getLikelyActiveCookie = () => getCurrentCookie() || getLastUsedCookie(); const COOKIE_USERHASH_DIGEST_CACHE_KEY = 'xdex-cookie-userhash-digests'; const cookieDigestInflight = new Map(); let cookieListUnavailableState = false; const LOGIN_PROMPT_SUPPRESS_KEY = 'loginPromptSuppressAuto'; const getLoginPromptSuppressAuto = () => !!GM_getValue(LOGIN_PROMPT_SUPPRESS_KEY, false); const setLoginPromptSuppressAuto = (v) => GM_setValue(LOGIN_PROMPT_SUPPRESS_KEY, !!v); function getCookieUserhashDigestCache() { const cache = GM_getValue(COOKIE_USERHASH_DIGEST_CACHE_KEY, {}); return cache && typeof cache === 'object' ? cache : {}; } function setCookieUserhashDigestCache(cache) { GM_setValue(COOKIE_USERHASH_DIGEST_CACHE_KEY, cache && typeof cache === 'object' ? cache : {}); } function getCurrentBrowserUserhash() { const match = String(document.cookie || '').match(/(?:^|;\s*)userhash=([^;]+)/); if (!match) return null; try { return decodeURIComponent(match[1]); } catch (e) { return match[1]; } } async function digestUserhash(raw) { if (!raw || !globalThis.crypto || !globalThis.crypto.subtle || typeof TextEncoder === 'undefined') return null; try { const data = new TextEncoder().encode(raw); const hash = await globalThis.crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); } catch (e) { return null; } } function getQueryParamPreservePlus(search, name) { const query = String(search || '').replace(/^\?/, ''); const pairs = query.split('&'); for (const pair of pairs) { const idx = pair.indexOf('='); const key = idx >= 0 ? pair.slice(0, idx) : pair; let decodedKey; try { decodedKey = decodeURIComponent(key); } catch (e) { decodedKey = key; } if (decodedKey !== name) continue; const value = idx >= 0 ? pair.slice(idx + 1) : ''; try { return decodeURIComponent(value); } catch (e) { return value; } } return null; } function decodeCookieExportText(text) { if (!text || typeof atob !== 'function' || typeof TextDecoder === 'undefined') return null; try { const bin = atob(text); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); const jsonText = new TextDecoder('utf-8').decode(bytes); try { return JSON.parse(jsonText); } catch (e) { return JSON.parse(jsonText.replace(/\+/g, '%20')); } } catch (e) { return null; } } function parseCookieExportUserhash(html) { const doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); const node = doc.querySelector('img[src*="text="]') || doc.querySelector('[src*="text="]'); const src = node && node.getAttribute('src'); if (!src) return null; try { const url = new URL(src, location.origin); const text = getQueryParamPreservePlus(url.search, 'text') || url.searchParams.get('text'); const data = decodeCookieExportText(text); return data && typeof data.cookie === 'string' ? data.cookie : null; } catch (e) { return null; } } function getCookieDigestCacheEntry(cookie) { return { id: cookie.id, name: cookie.name, desc: cookie.desc, updatedAt: Date.now() }; } async function fetchCookieExportUserhashDigest(cookie) { if (!cookie || !cookie.id) return null; const id = String(cookie.id); if (cookieDigestInflight.has(id)) return cookieDigestInflight.get(id); const promise = (async () => { try { const resp = await gmRequest(`https://www.nmbxd1.com/Member/User/Cookie/export/id/${encodeURIComponent(id)}.html`, 'text'); const raw = parseCookieExportUserhash(resp.responseText || ''); const digest = await digestUserhash(raw); return digest ? Object.assign(getCookieDigestCacheEntry(cookie), { digest }) : null; } catch (e) { return null; } })(); cookieDigestInflight.set(id, promise); try { return await promise; } finally { cookieDigestInflight.delete(id); } } async function syncCookieUserhashDigestCache(list) { const ids = Object.keys(list || {}); if (!ids.length) return; const cache = getCookieUserhashDigestCache(); const next = {}; const missing = []; ids.forEach(id => { const cookie = list[id]; if (!cookie) return; if (cache[id] && cache[id].digest) { next[id] = Object.assign({}, cache[id], getCookieDigestCacheEntry(cookie)); } else { missing.push(cookie); } }); setCookieUserhashDigestCache(next); if (!missing.length) return; const results = await Promise.allSettled(missing.map(fetchCookieExportUserhashDigest)); results.forEach(result => { if (result.status === 'fulfilled' && result.value && result.value.id && result.value.digest) { next[String(result.value.id)] = result.value; } }); setCookieUserhashDigestCache(next); } async function getCookieMatchedByCurrentUserhash() { const raw = getCurrentBrowserUserhash(); const digest = await digestUserhash(raw); if (!digest) return null; const cache = getCookieUserhashDigestCache(); const entry = Object.values(cache).find(item => item && item.digest === digest); return entry ? { id: entry.id, name: entry.name, desc: entry.desc } : null; } async function getLikelyActiveCookieAsync() { const matched = await getCookieMatchedByCurrentUserhash(); return matched || getLikelyActiveCookie(); } function removeDateString(){ $('#cookie-switcher-ui').find('*').addBack().contents() .filter(function(){ return this.nodeType===3; }) .each(function(){ this.nodeValue = this.nodeValue.replace(/ - 0000-00-00 00:00:00/g,''); }); } function updateCurrentCookieDisplay(cur){ // 当前饼干变化 → 同步串内偏好开关圆圈着色(置于 $d 守卫之前,各入口均可刷新) try { if (typeof refreshCookiePrefSwitchState === 'function') refreshCookiePrefSwitchState(); } catch (e) {} const $d = $('#current-cookie-display'); if(!$d.length) return; if(cookieListUnavailableState){ $d.text('待确认').css('color','red'); } else if(cur){ const nm = abbreviateName(cur.name); $d.text(nm + (cur.desc ? ' - ' + cur.desc : '')).css('color','#000'); } else { $d.text('无饼干').css('color','red'); //showLoginPrompt(); // 这里触发 } removeDateString(); // 饼干变化 → 冷却提示跟随切换(各饼干独立计时,切回仍显示剩余时间) try { renderCookieCooldown(); } catch (e) {} } // ---- 饼干发送冷却(服务端 15s 限制)---- // 只有「服务器明确回执成功」才记录计时起点:失败提交不消耗冷却,因此不进入冷却态。 // 切换饼干后按各自的起点独立显示;15s 走完提示自动消失。 const COOKIE_SUBMIT_COOLDOWN_MS = 15000; const COOKIE_COOLDOWN_STORE_KEY = 'xdex-cookie-cooldowns'; const COOKIE_COOLDOWN_TICK_MS = 500; let cookieCooldownTimer = 0; function getCookieCooldownStore() { const store = GM_getValue(COOKIE_COOLDOWN_STORE_KEY, {}); return store && typeof store === 'object' ? store : {}; } function setCookieCooldownAt(cookieId, atMs) { const id = String(cookieId || ''); if (!id) return; const store = getCookieCooldownStore(); if (Number(atMs) > 0) store[id] = Number(atMs); else delete store[id]; // 顺手清理已过期的键,避免记录无限增长 const now = Date.now(); Object.keys(store).forEach((k) => { if (!(now - Number(store[k]) < COOKIE_SUBMIT_COOLDOWN_MS)) delete store[k]; }); GM_setValue(COOKIE_COOLDOWN_STORE_KEY, store); } function getCookieCooldownRemainMs(cookie) { const cur = cookie || getCurrentCookie(); if (!cur || !cur.id) return 0; const at = Number(getCookieCooldownStore()[String(cur.id)] || 0); if (!at) return 0; const remain = at + COOKIE_SUBMIT_COOLDOWN_MS - Date.now(); return remain > 0 ? remain : 0; } // 提交成功:从此刻开始计时 function startCookieCooldown(cookieId) { const id = String(cookieId || (getCurrentCookie() || {}).id || ''); if (!id) return; setCookieCooldownAt(id, Date.now()); renderCookieCooldown(); } // 服务端回执里带「冷却时间[N]s」→ 用它校准剩余(仅在本饼干已有成功计时记录时生效, // 避免一次失败提交凭空造出冷却;这仍是「以服务器回复为准」的校准) function syncCookieCooldownFromServer(msg) { const m = String(msg == null ? '' : msg).match(/冷却时间\s*[[[]\s*(\d+)\s*[\]]]/); if (!m) return false; const cur = getCurrentCookie(); if (!cur || !cur.id) return false; const store = getCookieCooldownStore(); if (!store[String(cur.id)]) return false; const remainMs = Math.max(0, Number(m[1]) || 0) * 1000; setCookieCooldownAt(cur.id, Date.now() + remainMs - COOKIE_SUBMIT_COOLDOWN_MS); renderCookieCooldown(); return true; } // 跨页面同步:其它标签页发送成功后,本标签页(回复框已打开的)也要实时显示/更新冷却。 // 数据本身在 GM 存储共享;这里补的是「变更通知 → 重渲染」。 // 性能与生命周期: // · 全页面只注册一次(cookieCooldownSyncBound 守卫),绝不随饼干切换/面板刷新重复注册 // · 监听器随页面上下文销毁自动失效,无需手动注销 // · 事件只在该键被写入时投递(负载为整个 store,15s 冷却下每饼干每 15s 至多 1 次, // 实际大小 = 饼干数 × 数字,几十字节),回调仅做一次幂等的 renderCookieCooldown // · 本页自己的写入也会触发本监听(remote=false)——renderCookieCooldown 幂等,重复调用无害; // 且 renderCookieCooldown 内部有 cookieCooldownTimer 守卫,走秒 interval 不会重复叠加 let cookieCooldownSyncBound = false; function bindCookieCooldownStorageSync() { if (cookieCooldownSyncBound || typeof GM_addValueChangeListener !== 'function') return; cookieCooldownSyncBound = true; GM_addValueChangeListener(COOKIE_COOLDOWN_STORE_KEY, () => { try { renderCookieCooldown(); } catch (e) {} }); } function getCookieCooldownEl() { return document.querySelector('.xdex-cookie-cooldown'); } // 模块初始化:一次性注册跨标签同步监听(幂等,见上) bindCookieCooldownStorageSync(); // 冷却提示固定在「字数」左侧,二者同属送出按钮容器(右对齐) function ensureCookieCooldownEl() { const counter = document.querySelector('.xdex-post-content-char-count'); if (!counter || !counter.parentNode) return null; let el = counter.parentNode.querySelector('.xdex-cookie-cooldown'); if (!el) { el = document.createElement('span'); el.className = 'xdex-cookie-cooldown'; // margin-right:与「字数」之间留出间距(容器本身有 gap:6px,这里再加一点更清晰) el.style.cssText = 'font-size:12px;color:#888;white-space:nowrap;flex:0 0 auto;display:none;margin-right:4px;'; counter.parentNode.insertBefore(el, counter); } return el; } function stopCookieCooldownTicker() { if (!cookieCooldownTimer) return; clearInterval(cookieCooldownTimer); cookieCooldownTimer = 0; } function renderCookieCooldown() { const el = ensureCookieCooldownEl(); if (!el) return; const remain = getCookieCooldownRemainMs(); if (remain <= 0) { el.style.display = 'none'; el.textContent = ''; stopCookieCooldownTicker(); return; } const seconds = Math.max(1, Math.ceil(remain / 1000)); el.style.display = ''; // 默认区域(非拓展弹窗)空间窄,「冷却时间」前缀会与颜文字按钮重叠 → 只显示 [N]s; // 拓展弹窗(.qp-body 内)空间充足 → 显示完整「冷却时间[N]s」 const inPopup = !!(el.closest && el.closest('.qp-body')); // 数字盒定宽 2ch + 内部「右对齐」: // · 个位数(8)贴住右侧 ] ,两位数(15)自然填满 → 数字本身不跳 // · 盒子宽度不变 → 左侧「冷却时间[」与右侧「]s」「字数」都不移动 // vertical-align:-0.08em:15px 数字在基线上比 12px 灰字高约 1px(实测 delta -1px), // 下压后两者字形中心对齐(残差 0.19px),视觉上居中而非偏上 el.innerHTML = (inPopup ? '冷却时间' : '') + '[<span class="xdex-cookie-cooldown-num" ' + 'style="color:#e53935;font-size:15px;font-weight:700;line-height:1;' + 'display:inline-block;min-width:2ch;text-align:right;vertical-align:-0.08em;' + 'font-variant-numeric:tabular-nums;font-feature-settings:\'tnum\' 1;">' + seconds + '</span>]s'; if (!cookieCooldownTimer) { // 仅在存在冷却时走秒;冷却结束立即停表,不留常驻定时器 cookieCooldownTimer = setInterval(() => { const box = getCookieCooldownEl(); if (!box) { stopCookieCooldownTicker(); return; } if (getCookieCooldownRemainMs() <= 0) { box.style.display = 'none'; box.textContent = ''; stopCookieCooldownTicker(); return; } renderCookieCooldown(); }, COOKIE_COOLDOWN_TICK_MS); } } function updateDropdownUI(list){ const $dd = $('#cookie-dropdown'); $dd.empty(); const ids = Object.keys(list); ids.forEach(id=>{ const c=list[id]; const txt=abbreviateName(c.name)+(c.desc?' - '+c.desc:''); $dd.append(`<option value="${id}">${txt}</option>`); }); const cur = getCurrentCookie(); if (cur && list[cur.id]) { $dd.val(cur.id); } else { $dd.val(ids[0] || ''); } removeDateString(); } function scheduleCookieDropdownFocusRestore(dropdown, delay = 120) { if (!dropdown) return; const target = dropdown.__focusBackTarget; if (!target) return; if (dropdown.__focusBackTimer) { clearTimeout(dropdown.__focusBackTimer); } dropdown.__focusBackTimer = setTimeout(() => { dropdown.__focusBackTimer = null; if (document.activeElement === dropdown && target.isConnected) { try { target.focus(); } catch (e) {} } delete dropdown.__focusBackTarget; delete dropdown.__openedValue; }, delay); } function closeCookieShortcutMenu() { const existing = document.getElementById('xdex-cookie-shortcut-menu'); if (existing) existing.remove(); } function openCookieShortcutMenu(dropdown, focusBackTarget) { closeCookieShortcutMenu(); const options = Array.from(dropdown.options || []); if (!options.length) return false; const selectedValue = dropdown.value; const normalStyle = 'display:block;width:100%;box-sizing:border-box;border:0;background:transparent;color:inherit;text-align:left;padding:6px 12px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;outline:none;'; const selectedStyle = normalStyle + 'background:#eaf7ff;outline:2px solid #66ccff;outline-offset:-2px;box-shadow:inset 3px 0 0 #66ccff;'; const hoverStyle = normalStyle + 'background:#f2f2f2;'; const selectedHoverStyle = normalStyle + 'background:#dff3ff;outline:2px solid #66ccff;outline-offset:-2px;box-shadow:inset 3px 0 0 #66ccff;'; const activeStyle = normalStyle + 'background:#e8e8e8;outline:2px solid #66ccff;outline-offset:-2px;'; const activeSelectedStyle = normalStyle + 'background:#dff3ff;outline:2px solid #66ccff;outline-offset:-2px;box-shadow:inset 3px 0 0 #66ccff;'; const menu = document.createElement('div'); menu.id = 'xdex-cookie-shortcut-menu'; menu.tabIndex = -1; menu.style.cssText = 'position:fixed;z-index:2147483647;min-width:220px;max-width:420px;max-height:50vh;overflow:auto;background:#fff;color:#222;border:1px solid rgba(0,0,0,.25);border-radius:6px;box-shadow:0 4px 18px rgba(0,0,0,.25);font-size:14px;line-height:1.4;padding:4px 0;'; function applyCookieShortcutItemStyle(item) { if (!item) return; const isCurrent = item.dataset.current === '1'; const isActive = item.dataset.active === '1'; item.style.cssText = isActive ? (isCurrent ? activeSelectedStyle : activeStyle) : (isCurrent ? selectedStyle : normalStyle); } function setActiveCookieShortcutItem(nextItem) { menu.querySelectorAll('button[data-value]').forEach((item) => { item.dataset.active = item === nextItem ? '1' : '0'; applyCookieShortcutItemStyle(item); }); if (nextItem) { nextItem.focus(); nextItem.scrollIntoView({ block: 'nearest' }); } } options.forEach((option) => { const isSelected = option.value === selectedValue; const item = document.createElement('button'); item.type = 'button'; item.textContent = option.textContent || option.value; item.dataset.value = option.value; item.dataset.current = isSelected ? '1' : '0'; item.dataset.active = '0'; if (isSelected) item.setAttribute('aria-current', 'true'); applyCookieShortcutItemStyle(item); item.addEventListener('mouseenter', () => { item.style.cssText = isSelected ? selectedHoverStyle : hoverStyle; }); item.addEventListener('mouseleave', () => { applyCookieShortcutItemStyle(item); }); item.addEventListener('click', () => { dropdown.__focusBackTarget = focusBackTarget || dropdown.__focusBackTarget || document.activeElement || null; dropdown.value = item.dataset.value || ''; dropdown.dispatchEvent(new Event('change', { bubbles: true })); // 先移除菜单再交还焦点:菜单若仍在文档中,焦点会落到即将被移除的按钮上 closeCookieShortcutMenu(); if (typeof restorePostTextareaFocus === 'function') restorePostTextareaFocus(); }); menu.appendChild(item); }); const rect = dropdown.getBoundingClientRect(); document.documentElement.appendChild(menu); const width = Math.max(rect.width || 220, 220); menu.style.width = `${width}px`; const menuRect = menu.getBoundingClientRect(); const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - menuRect.width - 8)); const top = Math.min(Math.max(8, rect.bottom + 4), Math.max(8, window.innerHeight - menuRect.height - 8)); menu.style.left = `${left}px`; menu.style.top = `${top}px`; menu.addEventListener('keydown', (e) => { const items = Array.from(menu.querySelectorAll('button[data-value]')); const active = items.find((item) => item.dataset.active === '1') || document.activeElement; const index = Math.max(0, items.indexOf(active)); if (e.key === 'Escape') { e.preventDefault(); closeCookieShortcutMenu(); if (focusBackTarget && focusBackTarget.isConnected) focusBackTarget.focus(); } else if (e.key === 'ArrowDown') { e.preventDefault(); setActiveCookieShortcutItem(items[Math.min(items.length - 1, index + 1)]); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveCookieShortcutItem(items[Math.max(0, index - 1)]); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); active && active.click && active.click(); } }); document.addEventListener('mousedown', function onMouseDown(e) { if (menu.contains(e.target)) return; closeCookieShortcutMenu(); document.removeEventListener('mousedown', onMouseDown, true); }, true); const selected = Array.from(menu.querySelectorAll('button[data-value]')).find((item) => item.dataset.value === dropdown.value) || menu.querySelector('button[data-value]'); if (selected) setActiveCookieShortcutItem(selected); logCookieDropdownShortcutStage('custom-menu-opened', { options: options.length, value: dropdown.value }); return true; } // 把焦点交回发帖输入框(多处共用:切换饼干成功后、快捷菜单选择瞬间、弹窗取消等) function restorePostTextareaFocus() { const textarea = document.querySelector('textarea.h-post-form-textarea'); if (textarea && typeof textarea.focus === 'function') { try { textarea.focus(); } catch (e) {} } } function switch_cookie(cookie, opts = {}){ const silent = !!opts.silent; const onDone = typeof opts.onDone === 'function' ? opts.onDone : null; const onFail = typeof opts.onFail === 'function' ? opts.onFail : null; if(!cookie || !cookie.id) { if (!silent) toast('无效的饼干信息!', 0, { type: 'error' }); onFail && onFail(); return; } // ★ 选择即回归焦点:不等网络往返,避免换饼期间(网络延迟时)阻塞用户输入 if (opts.focusBackNow) restorePostTextareaFocus(); $.get(`https://www.nmbxd1.com/Member/User/Cookie/switchTo/id/${cookie.id}.html`) .done(()=>{ if (!silent) toast('切换成功! 当前饼干为 '+abbreviateName(cookie.name)); GM_setValue('now-cookie',cookie); GM_setValue(LAST_USED_COOKIE_KEY,cookie); updateCurrentCookieDisplay(cookie); updateDropdownUI(getCookiesList()); removeDateString(); updatePreviewCookieId(); // 切换成功后,将焦点移回到 textarea(兜底:即时恢复可能被后续 UI 更新打断) setTimeout(() => { restorePostTextareaFocus(); }, 100); onDone && onDone(cookie); }) .fail(()=>{ if (!silent) toast('切换失败,请重试', 0, { type: 'error' }); onFail && onFail(); }); } function autoApplyFirstCookieIfNeeded(list, opts = {}) { const ids = Object.keys(list || {}); if (!ids.length) return false; const cur = getCurrentCookie(); if (cur && list[cur.id]) return false; const lastUsed = getLastUsedCookie(); const preferred = lastUsed && list[lastUsed.id] ? list[lastUsed.id] : null; const first = list[ids[0]]; const target = preferred || first; if (!target || !target.id) return false; if (window.__cookieAutoApplying) return true; window.__cookieAutoApplying = true; switch_cookie(target, { silent: !!opts.silent, onDone: (cookie) => { window.__cookieAutoApplying = false; if (opts.showDefaultToast) { toast(preferred ? '已应用最近饼干:' + abbreviateName(cookie.name) : '已应用默认饼干:' + abbreviateName(cookie.name)); } if (typeof opts.onDone === 'function') opts.onDone(cookie); }, onFail: () => { window.__cookieAutoApplying = false; if (typeof opts.onFail === 'function') opts.onFail(); } }); return true; } function refreshCookies(cb, showToast = true, opts = {}){ GM_xmlhttpRequest({ method:'GET', url:'https://www.nmbxd1.com/Member/User/Cookie/index.html', onload: async r=>{ if(r.status!==200){ toast('刷新失败 HTTP '+r.status, 0, { type: 'error' }); return cb&&cb(); } const doc=new DOMParser().parseFromString(r.responseText,'text/html'); const rows=doc.querySelectorAll('tbody>tr'), list={}; rows.forEach(row=>{ const tds=row.querySelectorAll('td'); if(tds.length>=4){ const id=tds[1].textContent.trim(); const name=(tds[2].querySelector('a')||{}).textContent?.trim?.() || (tds[2].textContent||'').trim(); const desc=tds[3].textContent.trim(); list[id]={id,name,desc}; } }); GM_setValue('cookies',list); cookieListUnavailableState = isCookieListUnavailable(list); let actualCookieBeforeSync = null; if (!cookieListUnavailableState) { try { actualCookieBeforeSync = await getCookieMatchedByCurrentUserhash(); } catch (e) {} try { await syncCookieUserhashDigestCache(list); } catch (e) {} } updateDropdownUI(list); if (showToast) { toast('饼干列表已刷新!'); } let cur=getCurrentCookie(); let deletedActualCookie = null; if (cookieListUnavailableState) { cur = await getLikelyActiveCookieAsync(); if (!cur) cur = null; } else if (actualCookieBeforeSync && actualCookieBeforeSync.id) { if (list[actualCookieBeforeSync.id]) { cur = list[actualCookieBeforeSync.id]; } else { deletedActualCookie = actualCookieBeforeSync; cur = null; GM_setValue('now-cookie', null); } } else if(cur && !list[cur.id]) cur=null; // 若已登录且有饼干列表,但当前未应用任何饼干,则优先自动应用最近使用的饼干,否则应用第一个 if (!cur && Object.keys(list).length) { const started = autoApplyFirstCookieIfNeeded(list, { silent: true, showDefaultToast: !deletedActualCookie, onDone: (cookie) => { if (deletedActualCookie && cookie && cookie.name) { toast('当前饼干 ' + abbreviateName(deletedActualCookie.name) + ' 已被删除,已切换到 ' + abbreviateName(cookie.name)); } cb&&cb(); }, onFail: () => { GM_setValue('now-cookie', null); updateCurrentCookieDisplay(null); removeDateString(); updatePreviewCookieId(); cb&&cb(); } }); if (started) return; } GM_setValue('now-cookie',cur); updateCurrentCookieDisplay(cur); removeDateString(); updatePreviewCookieId(); // === 新增:检测是否无饼干,触发登录提示 === const $display = $('#current-cookie-display'); const $dropdown = $('#cookie-dropdown'); if ( !$dropdown.children().length || ($display.length && $display.text().trim() === '已删除') ) { showLoginPrompt(!!opts.manualPrompt); } cb&&cb(); }, onerror:()=>{ toast('刷新失败,网络错误', 0, { type: 'error' }); cb&&cb(); } }); } function ensureLoginPromptStyle() { if (document.getElementById('xdex-login-prompt-style')) return; const style = document.createElement('style'); style.id = 'xdex-login-prompt-style'; style.textContent = ` :root { --xdex-login-backdrop-bg: rgba(0,0,0,.45); --xdex-login-dialog-bg: #FFFFEE; --xdex-login-dialog-border: #d6c7ba; --xdex-login-dialog-text: #2f241d; --xdex-login-dialog-muted: #6a5546; --xdex-login-dialog-shadow: rgba(0,0,0,.28); --xdex-login-btn-bg: #f0e0d6; --xdex-login-btn-hover-bg: #e7d3c7; --xdex-login-btn-border: #c7b5a8; --xdex-login-btn-text: #4d392c; --xdex-login-btn-primary-bg: #66CCFF; --xdex-login-btn-primary-hover-bg: #54c2f7; --xdex-login-btn-primary-border: #59b9e7; --xdex-login-btn-primary-text: #fff; } :root.xdex-darkreader-active { --xdex-login-backdrop-bg: rgba(8,8,9,.62); --xdex-login-dialog-bg: #2F3233; --xdex-login-dialog-border: #3a2a21; --xdex-login-dialog-text: #f2dfc7; --xdex-login-dialog-muted: #d2bda4; --xdex-login-dialog-shadow: rgba(0,0,0,.6); --xdex-login-btn-bg: #3a2a21; --xdex-login-btn-hover-bg: #4a3429; --xdex-login-btn-border: #5d4334; --xdex-login-btn-text: #f2dfc7; --xdex-login-btn-primary-bg: #8fb3ff; --xdex-login-btn-primary-hover-bg: #a4c0ff; --xdex-login-btn-primary-border: #7da4f6; --xdex-login-btn-primary-text: #1a2232; } #login-modal-wrapper { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: flex-start; justify-content: center; } #login-modal-wrapper .login-backdrop { position: absolute; inset: 0; background: var(--xdex-login-backdrop-bg); } #login-modal-wrapper .login-dialog { position: relative; top: 30%; width: min(400px, calc(100vw - 32px)); padding: 20px; background: var(--xdex-login-dialog-bg); border: 1px solid var(--xdex-login-dialog-border); border-radius: 8px; box-shadow: 0 8px 24px var(--xdex-login-dialog-shadow); color: var(--xdex-login-dialog-text); z-index: 10001; box-sizing: border-box; } #login-modal-wrapper.xdex-login-embedded { align-items: center; } #login-modal-wrapper.xdex-login-embedded .login-dialog { top: 0; width: min(920px, calc(100vw - 24px)); height: min(88vh, 860px); padding: 12px; display: flex; flex-direction: column; gap: 8px; } #login-modal-wrapper.xdex-login-embedded .login-dialog.xdex-login-dialog-pending { visibility: hidden; } #login-modal-wrapper.xdex-login-embedded .login-dialog h2, #login-modal-wrapper.xdex-login-embedded .login-dialog p { flex: 0 0 auto; } #login-modal-wrapper.xdex-login-embedded .login-dialog-frame-wrap { flex: 1 1 auto; min-height: 0; border: 1px solid var(--xdex-login-dialog-border); border-radius: 6px; overflow: hidden; background: #fff; } #login-modal-wrapper.xdex-login-embedded .login-dialog-frame { display: block; width: 100%; height: 100%; border: 0; background: #fff; } #login-modal-wrapper.xdex-login-embedded .login-dialog-frame.xdex-login-frame-pending { visibility: hidden; } #login-modal-wrapper.xdex-login-embedded .login-dialog-status { min-height: 0; margin: 0; color: var(--xdex-login-dialog-muted); } #login-modal-wrapper.xdex-login-embedded .login-dialog-actions { margin-top: 4px; } #login-modal-wrapper .login-dialog h2 { margin: 0 0 12px; color: inherit; } #login-modal-wrapper .login-dialog p { margin: 0 0 8px; color: var(--xdex-login-dialog-muted); } #login-modal-wrapper .login-dialog-actions { margin-top: 16px; text-align: right; display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; } #login-modal-wrapper .login-dialog-actions button { margin-left: 10px; padding: 6px 12px; background: var(--xdex-login-btn-bg); border: 1px solid var(--xdex-login-btn-border); border-radius: 6px; color: var(--xdex-login-btn-text); box-shadow: none; cursor: pointer; } #login-modal-wrapper .login-dialog-actions button:hover, #login-modal-wrapper .login-dialog-actions button:focus { background: var(--xdex-login-btn-hover-bg); outline: none; } #login-modal-wrapper #login-open { background: var(--xdex-login-btn-primary-bg); border-color: var(--xdex-login-btn-primary-border); color: var(--xdex-login-btn-primary-text); } #login-modal-wrapper #login-open:hover, #login-modal-wrapper #login-open:focus { background: var(--xdex-login-btn-primary-hover-bg); } `; document.head.appendChild(style); } function disableVerifyInputMemory(root = document) { if (!root) return; const targets = []; if (root.nodeType === 1 && root.matches && (root.matches('#doc-ipt-verify-1') || root.matches('input[name="verify"]'))) { targets.push(root); } if (root.querySelectorAll) { root.querySelectorAll('#doc-ipt-verify-1, input[name="verify"]').forEach((el) => targets.push(el)); } targets.forEach((el) => { if (!el || el.dataset.xdexVerifyMemoryDisabled === '1') return; el.setAttribute('autocomplete', 'off'); el.setAttribute('autocapitalize', 'off'); el.setAttribute('autocorrect', 'off'); el.setAttribute('spellcheck', 'false'); el.dataset.xdexVerifyMemoryDisabled = '1'; }); } //done : 弹出登录提示弹窗已修复 function showLoginPrompt(force = false){ const url = window.location.href; const allowed = ( url.startsWith("https://www.nmbxd1.com/t/") || url.startsWith("https://www.nmbxd1.com/f/") || url.startsWith("https://www.nmbxd1.com/Forum/timeline/") ); if (!allowed) return; // 不在指定页面 → 直接退出 if (document.body && /429\s*Too Many Requests/i.test(document.body.textContent || '')) return; // 429 限流页面,不弹登录提示 if (!force && (window.__loginPromptSuppressUntilRefresh || getLoginPromptSuppressAuto())) return; // 持久化“不再提醒”后,自动场景不再弹出 if (!force && window.__loginPromptShown) return; // 自动触发只弹一次 if ($('#login-modal-wrapper').length) return; // 避免重复插入 window.__loginPromptShown = true; ensureLoginPromptStyle(); function updateLoggedOutCookieHint($dialog) { const $hint = $dialog.find('.login-cookie-hint'); if (!$hint.length) return; const applyLoginCookieMark = () => { if (typeof markAllCookies !== 'function') return; try { const cfg = getFilterConfig(); markAllCookies(cfg.markedGroups || [], $dialog[0]); } catch (e) {} }; const setHint = (prefix, cookieName, suffix) => { $hint.empty(); if (!cookieName) { $hint.text(prefix); return; } $hint.append(document.createTextNode(prefix)); $hint.append($('<span class="h-threads-info-uid"></span>').text(`ID:${cookieName}`)); $hint.append(document.createTextNode(suffix)); applyLoginCookieMark(); }; const likely = getLikelyActiveCookie(); if (likely && likely.name) { setHint('缓存中可用饼干可能为:', abbreviateName(likely.name), '。此时仍可作为该饼干回复。'); } else { setHint('当前仍可能按最后一次应用的饼干回复,但暂时无法确认具体饼干。'); } getCookieMatchedByCurrentUserhash().then(matched => { if (!matched || !matched.name || !isCookieListUnavailable(getCookiesList())) return; setHint('当前实际作用饼干为:', abbreviateName(matched.name), '。此时仍可作为该饼干回复。'); }); } let pollTimer = null; let pollTimeout = null; let isChecking = false; let embeddedMode = false; const cleanup = () => { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (pollTimeout) { clearTimeout(pollTimeout); pollTimeout = null; } isChecking = false; }; const closePrompt = () => { cleanup(); $m.fadeOut(200, () => $m.remove()); }; const refreshStatus = (text) => { $m.find('.login-dialog-status').text(text || ''); }; const checkLoginState = (onDone) => { if (isChecking) return; isChecking = true; refreshCookies(() => { isChecking = false; const hasCookies = Object.keys(getCookiesList() || {}).length > 0; if (hasCookies) { cleanup(); toast('登录成功,饼干已刷新'); closePrompt(); return; } if (typeof onDone === 'function') onDone(false); }, false, { manualPrompt: false }); }; const startPolling = () => { cleanup(); refreshStatus('Tips:已进入嵌入登录模式,完成后会自动检测。'); checkLoginState(); pollTimer = setInterval(() => { checkLoginState((ok) => { if (!ok) refreshStatus('Tips:登陆未完成'); }); }, 2000); pollTimeout = setTimeout(() => { cleanup(); refreshStatus('Tips:自动检测已停止,请在完成登录后点击“我已完成登录,刷新饼干”。'); }, 120000); }; const renderEmbeddedMode = () => { if (embeddedMode) return; embeddedMode = true; $m.addClass('xdex-login-embedded'); const $dialog = $m.find('.login-dialog'); $dialog.addClass('xdex-login-dialog-pending'); $dialog.html(` <h2>登录</h2> <div class="login-dialog-frame-wrap"> <iframe class="login-dialog-frame xdex-login-frame-pending" src="https://www.nmbxd1.com/Member/User/Index/login.html" title="登录页面"></iframe> </div> <p class="login-dialog-status"></p> <div class="login-dialog-actions"> <button id="login-refresh-after-login">我已完成登录,刷新饼干</button> <button id="login-close-embedded">关闭</button> </div> `); const compactEmbeddedLoginPage = (doc) => { if (!doc || !doc.head) return false; if (!/\/Member\/User\/Index\/login\.html(?:$|[?#])/.test(doc.location.href)) return false; if (doc.getElementById('xdex-compact-login-style')) return true; const style = doc.createElement('style'); style.id = 'xdex-compact-login-style'; style.textContent = ` html, body { height: auto !important; min-height: 0 !important; overflow: auto !important; } body[data-type="login"] { overflow: auto !important; } .myapp-login { height: auto !important; min-height: 0 !important; padding: 8px 0 !important; } .myapp-login-logo-block { padding: 0 !important; } .tpl-login-max { max-width: 520px !important; margin: 0 auto !important; } .myapp-login-logo-text { display: none !important; } .login-font { padding: 4px 0 8px !important; font-size: 11px !important; line-height: 1.2 !important; } .login-am-center { width: auto !important; max-width: 420px !important; float: none !important; } .login-am-center .am-form-group { margin-bottom: 8px !important; } .login-am-center .am-form input { height: 34px !important; line-height: 26px !important; font-size: 12px !important; } img[src*="/Member/User/Index/verify.html"] { margin-top: 4px !important; } .am-form p { margin: 8px 0 0 !important; } .login-am-center .am-btn-default { min-height: 34px !important; line-height: 28px !important; } `; doc.head.appendChild(style); return true; }; const applyEmbeddedLoginAdjustments = () => { const iframe = $m.find('.login-dialog-frame').get(0); if (!iframe) return; try { const doc = iframe.contentDocument; if (doc) { disableVerifyInputMemory(doc); if (compactEmbeddedLoginPage(doc)) { iframe.classList.remove('xdex-login-frame-pending'); $dialog.removeClass('xdex-login-dialog-pending'); } } } catch (e) {} }; const $iframe = $m.find('.login-dialog-frame'); $iframe.on('load', applyEmbeddedLoginAdjustments); applyEmbeddedLoginAdjustments(); $('#login-close-embedded').on('click', () => { closePrompt(); }); $('#login-refresh-after-login').on('click', () => { refreshStatus('Tips:正在检查登录状态...'); checkLoginState((ok) => { if (!ok) refreshStatus('Tips:仍未检测到饼干,请确认已完成登录。'); }); }); $m.off('click').on('click', (e) => { if (e.target.classList && e.target.classList.contains('login-backdrop')) { closePrompt(); } }); startPolling(); }; const $m = $(` <div id="login-modal-wrapper"> <!-- 遮罩层 --> <div class="login-backdrop"></div> <!-- 弹窗内容 --> <div class="login-dialog"> <h2>提示</h2> <p class="login-cookie-warning">当前已退出登录,无法切换饼干。</p> <p class="login-cookie-hint" style="color:red;">当前仍可能按最后一次应用的饼干回复,但暂时无法确认具体饼干。</p> <div class="login-dialog-actions"> <button id="login-open">去登录</button> <button id="login-no-remind">不再提醒</button> <!-- <button id="login-close">关闭</button> --> </div> </div> </div> `); $('body').append($m); updateLoggedOutCookieHint($m); $('#login-open').on('click', () => { toast('正在打开登录面板……'); renderEmbeddedMode(); }); $('#login-close').on('click', () => { closePrompt(); }); $('#login-no-remind').on('click', () => { window.__loginPromptSuppressUntilRefresh = true; setLoginPromptSuppressAuto(true); closePrompt(); }); $m.on('click', (e) => { if (e.target.classList && e.target.classList.contains('login-backdrop')) { closePrompt(); } }); } function createCookieSwitcherUI(){ const $title = $('.h-post-form-title:contains("回应模式")').first(); let $grid = $title.closest('.uk-grid.uk-grid-small.h-post-form-grid'); if(!$grid.length) $grid = $('.h-post-form-title:contains("名 称")').first() .closest('.uk-grid.uk-grid-small.h-post-form-grid'); if(!$grid.length) return; const cur = getCurrentCookie(), list = getCookiesList(); cookieListUnavailableState = isCookieListUnavailable(list); const $ui = $(` <div class="uk-grid uk-grid-small h-post-form-grid" id="cookie-switcher-ui" style="display: flex; flex-wrap: nowrap; align-items: center; width: 100%;"> <div class="uk-width-1-5"> <div class="h-post-form-title">饼干</div> </div> <div class="uk-width-4-5 h-post-form-input" style="display:flex;align-items:center;gap:8px;flex-wrap:nowrap;"> <div style="flex:1 1 auto;display:flex;align-items:center;gap:6px;min-width:3ch;"> <span id="current-cookie-display" style="max-width:40%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></span> <select id="cookie-dropdown" style="flex:1 1 auto;min-width:3ch;max-width:100%;"></select> </div> <button id="apply-cookie" class="uk-button uk-button-default" style="display:none;">应用</button> <div style="margin-left:auto;flex:0 0 auto;display:flex;align-items:center;"> <button id="refresh-cookie" class="xdex-icon-btn" title="刷新饼干列表" style="min-width:1em;text-align:center;">${XDEX_ICON_SYNC}</button> </div> </div> </div>`); $grid.before($ui); updateCurrentCookieDisplay(cur); updateDropdownUI(list); // 登录但未应用任何饼干时,优先自动应用最近使用的饼干,否则应用第一个 autoApplyFirstCookieIfNeeded(list, { silent: true, showDefaultToast: true }); // === 新增:检测是否无饼干,立即弹出登录提示 === const $display = $('#current-cookie-display'); const $dropdown = $('#cookie-dropdown'); if ( !$dropdown.children().length || ($display.length && $display.text().trim() === '无饼干') ) { showLoginPrompt(); } // 单击下拉项即切换饼干 $('#cookie-dropdown').on('change', function(){ const sel = $(this).val(); const l = getCookiesList(); if(!Object.keys(l).length) return showLoginPrompt(); if(!sel) return toast('请选择饼干', 0, { type: 'error' }); // focusBackNow:选择瞬间即交还焦点,不等 switch_cookie 的网络往返 l[sel] ? switch_cookie(l[sel], { focusBackNow: true }) : toast('饼干信息无效', 0, { type: 'error' }); scheduleCookieDropdownFocusRestore(this, 120); if (!this.__focusBackTarget) delete this.__openedValue; }); $('#cookie-dropdown').on('click', function(){ if (!this.__focusBackTarget) return; const openedValue = this.__openedValue; if (openedValue == null) return; if (String(this.value) === String(openedValue)) { scheduleCookieDropdownFocusRestore(this, 120); } }); $('#cookie-dropdown').on('keydown', function(e){ if (!(e && (e.key === ' ' || e.code === 'Space'))) return; if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return; e.preventDefault(); e.stopPropagation(); $(this).trigger('change'); }); $('#cookie-dropdown').on('blur', function(){ if (this.__focusBackTimer) { clearTimeout(this.__focusBackTimer); this.__focusBackTimer = null; } delete this.__focusBackTarget; delete this.__openedValue; }); // 刷新按钮 $('#refresh-cookie').on('click', e=>{ e.preventDefault(); // 用户手动点击“刷新”:若未登录,则作为“手动弹出”强制提示,不受“不再提醒”影响 window.__loginPromptShown = false; const _rcBtn = e.currentTarget; if (_rcBtn) _rcBtn.classList.add('xdex-icon-loading'); refreshCookies(() => { if (_rcBtn) _rcBtn.classList.remove('xdex-icon-loading'); }, true, { manualPrompt: true }); }); } /* -------------------------------------------------- * tag 6. 页面增强:页首页码 / 关闭水印 / 预览区真实饼干 / 隐藏无标题+无名氏+版规 * -------------------------------------------------- */ // ---- 旧版 enablePaginationDuplication(仅克隆 + 末页补全)---- // function enablePaginationDuplication (){ // const pags = document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); // if(!pags.length) return; // pags.forEach(pag => { // const tit = document.querySelector('h2.h-title'); // if(!tit || !pag) return; // const clone = pag.cloneNode(true); // tit.parentNode.insertBefore(clone, tit.nextSibling); // processPagination(clone); // }); // const observer = new MutationObserver(mutations => { // mutations.forEach(m => { // m.addedNodes.forEach(node => { // if(node.nodeType === 1){ // if(node.matches && node.matches('ul.uk-pagination.uk-pagination-left.h-pagination')){ // processPagination(node); // } // const innerPags = node.querySelectorAll?.('ul.uk-pagination.uk-pagination-left.h-pagination'); // innerPags?.forEach(p => processPagination(p)); // } // }); // }); // }); // observer.observe(document.body, { childList: true, subtree: true }); // } // ---- 旧版 rebuildPaginationPages ---- // function rebuildPaginationPages(pag) { // const totalPages = parseInt(pag.getAttribute('data-last-page'), 10); // if (!totalPages || totalPages < 1) return; // const activeLi = pag.querySelector('li.uk-active'); // if (!activeLi) return; // const activeEl = activeLi.querySelector('span, a'); // if (!activeEl) return; // const activeHref = activeEl.getAttribute('href') || ''; // const currentMatch = activeHref.match(/page=(\d+)/); // const currentPage = currentMatch ? parseInt(currentMatch[1], 10) : 1; // if (!currentPage) return; // const anyLink = pag.querySelector('a[href*="page="]'); // if (!anyLink) return; // const anyHref = anyLink.getAttribute('href') || ''; // const urlBase = anyHref.replace(/page=\d+/, 'page='); // if (!urlBase) return; // const WINDOW = 7; // const HALF = Math.floor(WINDOW / 2); // let start = Math.max(1, currentPage - HALF); // let end = Math.min(totalPages, start + WINDOW - 1); // start = Math.max(1, end - WINDOW + 1); // const allLi = Array.from(pag.children); // let prevPageLi = null; // let nextPageLi = null; // for (const li of allLi) { // const text = (li.textContent || '').trim(); // if (text === '上一页') prevPageLi = li; // else if (text === '下一页') nextPageLi = li; // } // if (!prevPageLi || !nextPageLi) return; // let removing = false; // for (const li of allLi) { // if (li === prevPageLi) { removing = true; continue; } // if (li === nextPageLi) break; // if (removing) li.remove(); // } // const fragment = document.createDocumentFragment(); // for (let p = start; p <= end; p++) { // const li = document.createElement('li'); // if (p === currentPage) { // li.className = 'uk-active'; // const span = document.createElement('span'); // span.setAttribute('href', urlBase + p); // span.textContent = String(p); // li.appendChild(span); // } else { // const a = document.createElement('a'); // a.setAttribute('href', urlBase + p); // a.setAttribute('achecked', '1'); // a.textContent = String(p); // li.appendChild(a); // } // fragment.appendChild(li); // } // pag.insertBefore(fragment, nextPageLi); // } function parsePaginationPageNum(href) { if (!href) return null; const value = String(href); let m = value.match(/[?&]page=(\d+)/); if (m) return parseInt(m[1], 10); // 支持 /page/N.html 与裸 /page/N(如 /t/{id}/page/3) m = value.match(/\/page\/(\d+)(?:\.html)?(?:[?#]|$)/); if (m) return parseInt(m[1], 10); m = value.match(/\/page\/(\d+)(?:\.html)?$/); if (m) return parseInt(m[1], 10); return null; } function buildPaginationHref(sampleHref, pageNum) { const href = String(sampleHref || ''); if (/[?&]page=\d+/.test(href)) return href.replace(/page=\d+/, `page=${pageNum}`); if (/\/page\/\d+\.html/.test(href)) return href.replace(/\/page\/\d+\.html/, `/page/${pageNum}.html`); if (isBoardPaginationContext() || PageType.isTimelinePage()) { const base = location.pathname.replace(/\/page\/\d+\.html$/, '').replace(/\.html$/, ''); return `${base}/page/${pageNum}.html`; } return href; } function isBoardPaginationContext() { return /^\/f\//.test(location.pathname); } // 重建分页栏页码:显示 7 个页码,当前页居中(边界时左/右对齐) function rebuildPaginationPages(pag) { // 1) 提取分页链接样本:支持串内 ?page=N,也支持版块页 /page/N.html const anyLink = Array.from(pag.querySelectorAll('a[href], span[href]')).find(el => parsePaginationPageNum(el.getAttribute('href'))); if (!anyLink) return; const sampleHref = anyLink.getAttribute('href') || ''; // 2) 提取当前页码:优先 uk-active,回退到 data-cloned-page,再回退到上一页+1 // (先提取当前页,后面计算总页数需要用到) let currentPage = 0; const activeLi = pag.querySelector('li.uk-active'); if (activeLi) { const activeEl = activeLi.querySelector('span, a'); if (activeEl) { const activeHref = activeEl.getAttribute('href') || ''; currentPage = parsePaginationPageNum(activeHref) || 0; if (!currentPage && /^\d+$/.test((activeEl.textContent || '').trim())) currentPage = parseInt((activeEl.textContent || '').trim(), 10); } } if (!currentPage) { currentPage = parseInt(pag.getAttribute('data-cloned-page'), 10) || 0; } if (!currentPage) { const prevLink = Array.from(pag.querySelectorAll('a')).find(a => (a.textContent || '').trim() === '上一页'); if (prevLink) { const prevHref = prevLink.getAttribute('href') || ''; const prevPage = parsePaginationPageNum(prevHref); currentPage = prevPage ? prevPage + 1 : 0; } } if (!currentPage) currentPage = 1; // 3) 提取总页数(多级回退) let totalPages = 0; // 回退1:末页链接的 href const lastLink = Array.from(pag.querySelectorAll('a')).find(a => (a.textContent || '').trim().startsWith('末页')); const hasExplicitLastPage = !!lastLink; if (lastLink) { const lastHref = lastLink.getAttribute('href') || ''; totalPages = parsePaginationPageNum(lastHref) || 0; } // 回退2:data-last-page(无缝翻页的属性,可能是翻页前的页码而非真正的总页数) if (!totalPages || totalPages < 1) { totalPages = parseInt(pag.getAttribute('data-last-page'), 10) || 0; } // 回退3:从所有页码链接中取最大值 if (!totalPages || totalPages < 1) { let maxPage = 0; pag.querySelectorAll('a[href], span[href]').forEach(el => { const n = parsePaginationPageNum(el.getAttribute('href') || ''); if (n) maxPage = Math.max(maxPage, n); }); totalPages = maxPage; } // 版块页最多展示最近 100 页;/f/ 通常没有“末页”链接,不能把当前 DOM 的 1/2/3 误判为总页数 if (isBoardPaginationContext()) { totalPages = hasExplicitLastPage && totalPages ? Math.min(100, totalPages) : 100; if (currentPage > totalPages) totalPages = Math.min(100, currentPage); } // 关键修正:如果"下一页"被禁用(uk-disabled),说明当前就是末页 const nextPageCandidate = Array.from(pag.children).find(li => (li.textContent || '').trim() === '下一页'); if (nextPageCandidate && nextPageCandidate.classList.contains('uk-disabled')) { totalPages = Math.max(totalPages, currentPage); } // 兜底:总页数不能小于当前页 if (totalPages < currentPage) totalPages = currentPage; if (!totalPages) return; // console.debug('[rebuildPaginationPages]', { totalPages, currentPage, sampleHref }); // 4) 计算页码窗口 const WINDOW = 7; const HALF = Math.floor(WINDOW / 2); let start = Math.max(1, currentPage - HALF); let end = Math.min(totalPages, start + WINDOW - 1); start = Math.max(1, end - WINDOW + 1); // 幂等检查:如果上一页/下一页之间已经是目标页码,就不要再改 DOM,避免 observer 自触发循环 const currentPageItems = Array.from(pag.children) .filter(li => { const text = (li.textContent || '').trim(); return /^\d+$/.test(text); }) .map(li => ({ text: (li.textContent || '').trim(), active: li.classList.contains('uk-active') })); const expectedPageItems = []; for (let p = start; p <= end; p++) expectedPageItems.push({ text: String(p), active: p === currentPage }); const alreadyMatches = currentPageItems.length === expectedPageItems.length && currentPageItems.every((item, idx) => item.text === expectedPageItems[idx].text && item.active === expectedPageItems[idx].active); if (alreadyMatches) { pag.setAttribute('data-xdex-rebuilt', '1'); return; } // 5) 定位上一页/下一页按钮 const allLi = Array.from(pag.children); let prevPageLi = null; let nextPageLi = null; for (const li of allLi) { const text = (li.textContent || '').trim(); if (text === '上一页') prevPageLi = li; else if (text === '下一页') nextPageLi = li; } if (!prevPageLi || !nextPageLi) return; // 6) 移除上一页和下一页之间的旧页码 <li> let removing = false; for (const li of allLi) { if (li === prevPageLi) { removing = true; continue; } if (li === nextPageLi) break; if (removing) li.remove(); } // 7) 生成新页码 <li> 并插入 const fragment = document.createDocumentFragment(); for (let p = start; p <= end; p++) { const li = document.createElement('li'); if (p === currentPage) { li.className = 'uk-active'; const span = document.createElement('span'); span.setAttribute('href', buildPaginationHref(sampleHref, p)); span.textContent = String(p); li.appendChild(span); } else { const a = document.createElement('a'); a.setAttribute('href', buildPaginationHref(sampleHref, p)); a.setAttribute('achecked', '1'); a.textContent = String(p); li.appendChild(a); } fragment.appendChild(li); } pag.insertBefore(fragment, nextPageLi); // 标记已重建,避免延迟重扫或 MutationObserver 重复处理 pag.setAttribute('data-xdex-rebuilt', '1'); } function enablePaginationDuplication(cloneTitlePagination = true){ // 获取所有分页栏,而不是只获取一个 const pags = Array.from(document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination')); if(!pags.length) return; const tit = cloneTitlePagination ? document.querySelector('h2.h-title') : null; let titlePag = null; if (tit) { // early pass 和 ready 兜底都可能运行;标题后页码栏全页只保留一条 const titlePags = pags.filter(pag => pag.dataset.xdexTitleClone === '1' || pag.previousElementSibling === tit); titlePags.forEach((pag, idx) => { if (idx === 0) { titlePag = pag; titlePag.dataset.xdexTitleClone = '1'; } else { console.info('[X岛-EX][页首页码] 去重:移除多余标题页码栏 (prev=' + (pag.previousElementSibling ? pag.previousElementSibling.tagName + '.' + String(pag.previousElementSibling.className).split(' ')[0] : 'null') + ')'); pag.remove(); } }); } const sourcePags = pags.filter(pag => pag.isConnected && pag.dataset.xdexTitleClone !== '1' && pag.previousElementSibling !== tit); sourcePags.forEach(pag => { // 重建页码(显示 7 个页码,当前页居中) rebuildPaginationPages(pag); // 末页补全 processPagination(pag); }); if (tit && !titlePag && sourcePags.length) { // 克隆分页栏并插入标题后 titlePag = sourcePags[0].cloneNode(true); titlePag.dataset.xdexTitleClone = '1'; tit.parentNode.insertBefore(titlePag, tit.nextSibling); console.info('[X岛-EX][页首页码] 已创建页首页码栏 (来源=' + (sourcePags[0].previousElementSibling ? sourcePags[0].previousElementSibling.tagName : '?') + ')'); } if (titlePag && titlePag.isConnected) { rebuildPaginationPages(titlePag); // 对克隆分页栏执行末页补全(页码已随克隆复制) processPagination(titlePag); } // 延迟重建:防止其他功能(如无缝翻页初始化)在之后覆盖分页栏 function rescanAndRebuild() { document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination:not([data-xdex-rebuilt])').forEach(pag => { rebuildPaginationPages(pag); processPagination(pag); pag.setAttribute('data-xdex-rebuilt', '1'); }); } [500, 1500].forEach(ms => setTimeout(rescanAndRebuild, ms)); // 监听 DOM 变化,自动处理后续新增/替换的分页栏 // 用防抖避免 rebuild 改 DOM 后触发 observer 造成无限循环 const pagDebounceTimers = new WeakMap(); const observer = new MutationObserver(mutations => { const targets = new Set(); mutations.forEach(m => { m.addedNodes.forEach(node => { if(node.nodeType !== 1) return; // 情况1:新增的节点本身是分页栏 if(node.matches && node.matches('ul.uk-pagination.uk-pagination-left.h-pagination')){ targets.add(node); return; } // 情况2:新增的节点内部包含分页栏 const innerPags = node.querySelectorAll?.('ul.uk-pagination.uk-pagination-left.h-pagination'); innerPags?.forEach(p => targets.add(p)); // 情况3:新增的节点是分页栏的子节点(如 <li> 被替换),向上查找父分页栏 const parentPag = node.closest?.('ul.uk-pagination.uk-pagination-left.h-pagination'); if (parentPag) targets.add(parentPag); }); }); // 对每个受影响的分页栏做防抖重建(50ms 内重复变化只触发一次) targets.forEach(pag => { if (pagDebounceTimers.has(pag)) clearTimeout(pagDebounceTimers.get(pag)); pagDebounceTimers.set(pag, setTimeout(() => { pagDebounceTimers.delete(pag); if (pag.dataset.xdexTitleClone === '1' || pag.previousElementSibling?.matches?.('h2.h-title')) { const tit = document.querySelector('h2.h-title'); const titlePags = Array.from(document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination')) .filter(p => p.dataset.xdexTitleClone === '1' || p.previousElementSibling === tit); titlePags.forEach((p, idx) => { if (idx === 0) { p.dataset.xdexTitleClone = '1'; rebuildPaginationPages(p); processPagination(p); } else { p.remove(); } }); return; } rebuildPaginationPages(pag); processPagination(pag); }, 50)); }); }); observer.observe(document.body, { childList: true, subtree: true }); } // 专门处理“末页”按钮的函数 function processPagination(pag){ pag.querySelectorAll('a').forEach(a => { if(a.textContent.trim().startsWith('末页')){ // 如果已经有页码则跳过 if(/\(\d+\)$/.test(a.textContent.trim())) return; // 第一种格式:?page=13 let m = a.href.match(/page=(\d+)/); // 第二种格式:/page/6.html if(!m){ m = a.href.match(/\/page\/(\d+)\.html/); } if(m){ a.textContent = `末页(${m[1]})`; } } }); } const disableWatermark = () => { const c = document.querySelector('input[type="checkbox"][name="water"][value="true"]'); if(c) c.checked = false; }; function updatePreviewCookieId(){ if(!$('.h-preview-box').length) return; const cur=getCurrentCookie(); const $uid = $('.h-preview-box .h-threads-info-uid'); const listUnavailable = isCookieListUnavailable(getCookiesList()); if (listUnavailable) { const likely = getLikelyActiveCookie(); const name = likely && likely.name ? abbreviateName(likely.name) : '未知'; $uid.text(`已退出登录,无法获取列表,当前饼干可能为:${name}`).css('color', 'red'); getCookieMatchedByCurrentUserhash().then(matched => { if (!matched || !matched.name || !isCookieListUnavailable(getCookiesList())) return; $uid.text(`已退出登录,无法切换饼干,当前实际作用饼干为:${abbreviateName(matched.name)}`).css('color', 'red'); if (typeof markAllCookies === 'function') { try { const cfg = getFilterConfig(); markAllCookies(cfg.markedGroups || [], $('.h-preview-box')[0]); } catch (e) {} } }); } else { const name=cur&&cur.name?abbreviateName(cur.name):'无饼干'; $uid.text('ID:'+name).css('color', ''); cookieListUnavailableState = false; } // 切换饼干后更新标记背景色 if (typeof markAllCookies === 'function') { try { const cfg = getFilterConfig(); markAllCookies(cfg.markedGroups || [], $('.h-preview-box')[0]); } catch (e) {} } } function hideEmptyTitleAndEmail(root){ const $root = root ? $(root) : null; const $titles = $root ? $root.find('.h-threads-info-title').add($root.filter('.h-threads-info-title')) : $('.h-threads-info-title'); const $emails = $root ? $root.find('.h-threads-info-email').add($root.filter('.h-threads-info-email')) : $('.h-threads-info-email'); $titles.each(function(){ if ($(this).text().trim() === '无标题' && this.style.display !== 'none') { this.style.display = 'none'; } }); $emails.each(function(){ if ($(this).text().trim() === '无名氏' && this.style.display !== 'none') { this.style.display = 'none'; } }); } function getEarlyStartupConfig() { try { return Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); } catch (e) { return SettingPanel.defaults; } } function collapseEarlyStartupBlocks(root, cfg) { if (!cfg || !cfg.hideEmptyTitleEmail || typeof Utils === 'undefined' || typeof Utils.collapse !== 'function') return; const $root = root ? $(root) : $(document); Utils.collapse($root.find('.h-forum-header').addBack('.h-forum-header'), '『版规』'); Utils.collapse($root.find('form[action="/Home/Forum/doReplyThread.html"]').addBack('form[action="/Home/Forum/doReplyThread.html"]'), '『回复』'); Utils.collapse($root.find('form[action="/Home/Forum/doPostThread.html"]').addBack('form[action="/Home/Forum/doPostThread.html"]'), '『发串』'); } // === 全局:实时定位当前真正在屏幕上的预览框(浮窗内优先)=== function getLivePreviewBox() { return document.querySelector('.qp-body .h-preview-box') || document.querySelector('#h-post-form .h-preview-box') || document.querySelector('.h-preview-box'); } function looksLikeHeifFile(file) { if (!file) return false; const type = String(file.type || '').toLowerCase(); if (type === 'image/heic' || type === 'image/heif' || type === 'image/heic-sequence' || type === 'image/heif-sequence') { return true; } const name = String(file.name || ''); return /\.(heic|heif|hif)$/i.test(name); } let lastHeifPreviewTipKey = ''; let lastHeifPreviewTipFile = null; let lastHeifPreviewTipAt = 0; let heifPreviewTipSeq = 0; // 清除附件(清空按钮 / 原生 input 清空 / 发送成功后清空)时必须调用, // 否则同一个 HEIC 文件重新附带会被去重键挡住、不再提示。 function resetHeifPreviewTipState() { lastHeifPreviewTipKey = ''; lastHeifPreviewTipFile = null; lastHeifPreviewTipAt = 0; } function maybeToastHeifPreviewUnsupported(file) { if (!file) { resetHeifPreviewTipState(); return; } const key = [file.name || '', file.size || 0, file.lastModified || 0].join('|'); const now = Date.now(); if (key === lastHeifPreviewTipKey) { // 同一次附带动作内会被多次调用(粘贴处理器 → 写入 input → change → 初始化补渲染), // 这些调用可能拿到同一个 File 的不同实例:同一实例直接去重,不同实例只在短窗口内去重。 if (file === lastHeifPreviewTipFile) return; if (now - lastHeifPreviewTipAt < 1200) return; } lastHeifPreviewTipKey = key; lastHeifPreviewTipFile = file; lastHeifPreviewTipAt = now; toast('当前不支持 HEIF/HEIC 预览(发送时将自动转为PNG)', 2500); } function notifyHeifPreviewUnsupportedIfNeeded(file, seq) { if (!file) { resetHeifPreviewTipState(); return; } // 快路径:MIME/扩展名可立刻提示 if (looksLikeHeifFile(file)) { if (seq === heifPreviewTipSeq) maybeToastHeifPreviewUnsupported(file); return; } // 慢路径:无扩展名/MIME 时用魔数确认(与提交侧 detectImageFormat 一致) if (typeof detectImageFormat !== 'function') return; Promise.resolve(detectImageFormat(file)).then((fmt) => { if (seq !== heifPreviewTipSeq) return; if (fmt === 'heic') maybeToastHeifPreviewUnsupported(file); }).catch(() => {}); } // === 全局:把图片文件渲染到当前活跃预览框 === // 不依赖任何闭包快照,每次实时查询预览框与所在位置,避免 REPLY 浮窗 // open() 早于 enhanceIsland 初始化时,粘贴图片更新到看不见的框。 function updatePreviewImageFromFile(file) { // HEIF 预览:浏览器 <img>+blob 通常无法解码,仅提示;不改 input 中的原文件 const tipSeq = ++heifPreviewTipSeq; if (file) notifyHeifPreviewUnsupportedIfNeeded(file, tipSeq); else resetHeifPreviewTipState(); const liveBox = getLivePreviewBox(); if (!liveBox) return; const $liveBox = $(liveBox); const imgEl = $liveBox.find('.h-threads-img')[0]; const imgLink = $liveBox.find('.h-threads-img-a')[0]; const toolLarge = $liveBox.find('.h-threads-img-tool-large')[0]; const imgBox = $liveBox.find('.h-threads-img-box')[0]; if (!imgEl) return; if (imgEl.dataset.prevObjectUrl) { URL.revokeObjectURL(imgEl.dataset.prevObjectUrl); delete imgEl.dataset.prevObjectUrl; } if (file) { const objectUrl = URL.createObjectURL(file); imgEl.src = objectUrl; imgEl.dataset.prevObjectUrl = objectUrl; imgEl.style.display = 'block'; if (imgLink) imgLink.href = objectUrl; if (toolLarge) toolLarge.href = objectUrl; if (imgBox) imgBox.classList.remove('h-active'); imgEl.dataset.rotateIndex = '0'; imgEl.style.transform = ''; imgEl.style.top = '0px'; imgEl.style.left = '0px'; imgEl.style.width = 'auto'; imgEl.style.height = 'auto'; const isInOverlay = !!$liveBox.closest('.qp-body').length; if (isInOverlay) { const wrapEl = $liveBox.closest('.qp-content-wrap')[0]; if (wrapEl) { const wrapWidth = wrapEl.getBoundingClientRect().width; imgEl.style.maxWidth = (wrapWidth / 3) + 'px'; imgEl.style.height = 'auto'; } } else { const formEl = document.querySelector('#h-post-form form'); if (formEl) { const formWidth = formEl.getBoundingClientRect().width; imgEl.style.maxWidth = (formWidth / 2) + 'px'; imgEl.style.height = 'auto'; } } } else { imgEl.removeAttribute('src'); imgEl.style.display = 'none'; imgEl.style.maxWidth = ''; imgEl.style.width = ''; imgEl.style.height = ''; imgEl.style.transform = ''; imgEl.style.top = '0px'; imgEl.style.left = '0px'; delete imgEl.dataset.rotateIndex; if (imgLink) { imgLink.removeAttribute('href'); imgLink.style.height = ''; } if (toolLarge) toolLarge.href = 'javascript:;'; if (imgBox) imgBox.classList.remove('h-active'); } } // === 全局:绑定粘贴图片预览(幂等),供 enhanceIsland 与 REPLY 浮窗 open() 共用 === function bindPasteImagePreviewOnce() { if (bindPasteImagePreviewOnce.__bound) return; bindPasteImagePreviewOnce.__bound = true; document.addEventListener('paste', function (e) { const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items || []; if (!items.length) return; let file = null; for (const it of items) { if (it.kind === 'file') { const f = it.getAsFile(); if (f && f.type.startsWith('image/')) { file = f; break; } } } if (!file) return; // 优先取浮窗内的 file input,否则取原表单的 const fileInput = document.querySelector('.qp-body input[type="file"][name="image"]') || document.querySelector('#h-post-form input[type="file"][name="image"]') || document.querySelector('input[type="file"][name="image"]'); if (fileInput) { try { const dt = new DataTransfer(); dt.items.add(file); fileInput.files = dt.files; } catch (_) {} } updatePreviewImageFromFile(file); if (fileInput) { try { fileInput.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {} } }, true); } const PREVIEW_ID_POOL = ['0712','0711','0412','1002','1210','0520','1105','0217','0831','1227','1227','0130','0812','1126','0722','0119','0520','0630','0210','1110','1227']; let previewPlaceholderId = null; function getPreviewPlaceholderId(reuse) { // reuse=true(早期注入与正式绑定):复用同一编号,避免替换时视觉割裂;reuse=false(发送后重建/重置):重新随机 if (!reuse || !previewPlaceholderId) { previewPlaceholderId = PREVIEW_ID_POOL[Math.floor(Math.random() * PREVIEW_ID_POOL.length)]; } return previewPlaceholderId; } function buildEnhanceIslandPreviewHtml(early) { // 从 cookie-switcher 里取当前饼干 const cookieDisplay = document.querySelector('#h-post-form #current-cookie-display'); const cookieText = cookieDisplay ? cookieDisplay.textContent.trim() : '--'; return ` <div class="h-preview-box"${early ? ' data-xdex-early-preview="1"' : ''}> <div class="h-threads-item"> <div class="h-threads-item-replies"> <div class="h-threads-item-reply" style="width:100%"> <div class="h-threads-item-reply-main"> <div class="h-threads-img-box"> <div class="h-threads-img-tool uk-animation-slide-top"> <span class="h-threads-img-tool-btn h-threads-img-tool-small uk-button-link"><i class="uk-icon-minus"></i>收起</span> <a href="javascript:;" class="h-threads-img-tool-btn h-threads-img-tool-large uk-button-link"><i class="uk-icon-search-plus"></i>查看大图</a> <span class="h-threads-img-tool-btn h-threads-img-tool-left uk-button-link"><i class="uk-icon-reply"></i>向左旋转</span> <span class="h-threads-img-tool-btn h-threads-img-tool-right uk-button-link"><i class="uk-icon-share"></i>向右旋转</span> </div> <a class="h-threads-img-a"><img src="" align="left" border="0" hspace="20" class="h-threads-img"></a> </div> <div class="h-threads-info"> <span class="h-threads-info-title"></span> <span class="h-threads-info-email"></span> <span class="h-threads-info-createdat">2013-07-11(六)12:07:12</span> <span class="h-threads-info-uid">ID:${cookieText}</span> <!-- <span class="h-threads-info-report-btn"> [<a href="/f/值班室" target="_blank">举报</a>] </span> --> <a class="h-threads-info-id" href="javascript:;" style="cursor: default;">No.${getPreviewPlaceholderId(early)}</a> </div> <div class="h-threads-content"></div> </div> </div> </div> </div> </div>`; } function applyEarlyPreviewBoxStyle(previewEl) { if (!previewEl) return; previewEl.style.width = '100%'; previewEl.style.boxSizing = 'border-box'; const content = previewEl.querySelector('.h-threads-content'); if (content) { content.style.overflowWrap = 'break-word'; content.style.wordBreak = 'break-word'; content.style.whiteSpace = 'normal'; } } function tryInsertPreviewBoxEarly(formOpt) { if (!document.body) return false; // 已有预览框则复用(浮窗内优先) const existing = document.querySelector('.qp-body .h-preview-box') || document.querySelector('.h-preview-box'); if (existing) return true; const form = formOpt || document.querySelector('#h-post-form form') || document.querySelector('form[action="/Home/Forum/doReplyThread.html"]') || document.querySelector('form[action="/Home/Forum/doPostThread.html"]'); if (!form || !form.parentNode) return false; form.insertAdjacentHTML('afterend', buildEnhanceIslandPreviewHtml(true)); const previewEl = form.nextElementSibling && form.nextElementSibling.classList?.contains('h-preview-box') ? form.nextElementSibling : document.querySelector('.h-preview-box'); applyEarlyPreviewBoxStyle(previewEl); if (typeof updatePreviewCookieId === 'function') { try { updatePreviewCookieId(); } catch (e) {} } return !!previewEl; } function tryEnablePaginationDuplicationEarly(root) { const scope = root && root.querySelectorAll ? root : document; const tit = document.querySelector('h2.h-title'); const pags = []; if (scope.matches && scope.matches('ul.uk-pagination.uk-pagination-left.h-pagination')) pags.push(scope); scope.querySelectorAll?.('ul.uk-pagination.uk-pagination-left.h-pagination').forEach(p => pags.push(p)); if (!pags.length) return false; let titlePag = null; if (tit) { const titlePags = pags.filter(pag => pag.dataset.xdexTitleClone === '1' || pag.previousElementSibling === tit); titlePags.forEach((pag, idx) => { if (idx === 0) { titlePag = pag; titlePag.dataset.xdexTitleClone = '1'; } else { pag.remove(); } }); } let changed = false; pags.forEach(pag => { if (!pag.isConnected) return; try { if (typeof rebuildPaginationPages === 'function') rebuildPaginationPages(pag); } catch (e) {} try { if (typeof processPagination === 'function') processPagination(pag); } catch (e) {} if (!tit || titlePag || pag.dataset.xdexTitleClone === '1' || pag.previousElementSibling === tit) return; const clone = pag.cloneNode(true); clone.dataset.xdexTitleClone = '1'; tit.parentNode.insertBefore(clone, tit.nextSibling); try { if (typeof processPagination === 'function') processPagination(clone); } catch (e) {} titlePag = clone; changed = true; }); return changed; } function runCriticalVisualEnhancements(root, cfg) { const liveCfg = cfg || getEarlyStartupConfig(); try { if (liveCfg.hideEmptyTitleEmail && typeof hideEmptyTitleAndEmail === 'function') hideEmptyTitleAndEmail(root); } catch (e) {} try { if (liveCfg.hideEmptyTitleEmail) collapseEarlyStartupBlocks(root, liveCfg); } catch (e) {} try { if (typeof highlightPO === 'function') highlightPO(root); } catch (e) {} try { if (typeof refreshFilterDisplay === 'function') refreshFilterDisplay(liveCfg, root || document); } catch (e) {} try { if (liveCfg.enableRelativeTime && typeof formatDateStrOnPage === 'function') formatDateStrOnPage(root || document); } catch (e) {} try { if (typeof autoSelectReportReason === 'function') autoSelectReportReason(root || document); } catch (e) {} try { if (liveCfg.enablePaginationDuplication) tryEnablePaginationDuplicationEarly(root); } catch (e) {} try { if (liveCfg.updatePreviewCookie) tryInsertPreviewBoxEarly(); } catch (e) {} try { if (typeof tryReplaceRightSidebarEarly === 'function') tryReplaceRightSidebarEarly(); } catch (e) {} try { // tag 30:测试板块侧边栏入口(每次页面加载 / 局部增强都会补挂,含观察器兜底) if (typeof installTestBoardSidebarLinkObserver === 'function') installTestBoardSidebarLinkObserver(); // tag 30:测试版主串列表守护(抢在站点内联脚本移除前装好 removedNodes 守护器) if (typeof installTestBoardListGuard === 'function') installTestBoardListGuard(); // tag 30:测试版主串列表恢复兜底(守护器失手时网络拉取放回) if (typeof restoreTestBoardThreadList === 'function') restoreTestBoardThreadList(); } catch (e) {} // 顶栏刷新点击:越早越好,不依赖 batch2 try { if (typeof installOverrideTopImageClickEarly === 'function') installOverrideTopImageClickEarly(); else if (typeof overrideTopImageClick === 'function') overrideTopImageClick(); } catch (e) {} } function runEarlyStartupPass(root) { runCriticalVisualEnhancements(root, getEarlyStartupConfig()); } function installEarlyCriticalVisualPass() { const startedAt = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); const duration = 800; let rafId = 0; function now() { return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); } function pass() { rafId = 0; try { runCriticalVisualEnhancements(document, getEarlyStartupConfig()); } catch (e) {} if (now() - startedAt < duration) { rafId = requestAnimationFrame(pass); } } pass(); setTimeout(() => { if (rafId) cancelAnimationFrame(rafId); }, duration + 250); } function getEnhanceIslandOriginalTitle() { const root = document.documentElement; if (!root) return document.title; if (!root.dataset.xdexOriginalTitle) { root.dataset.xdexOriginalTitle = document.title; } return root.dataset.xdexOriginalTitle || document.title; } function selectEnhanceIslandTitleText() { const titleEl = document.querySelector('.h-threads-list .h-threads-item-main .h-threads-info .h-threads-info-title'); const contentEl = document.querySelector('.h-threads-list .h-threads-item-main .h-threads-content'); if (!contentEl) return ''; const titleText = (titleEl?.textContent || '').trim(); if (titleText && titleText !== '无标题') return titleText; const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_ELEMENT); let node = contentEl; while (node) { try { if (window.getComputedStyle(node).color === 'rgb(255, 0, 0)') { const redSegment = node.textContent.replace(/^[=\s+]+|[=\s+]+$/g, ''); if (redSegment) return redSegment; } } catch (_) {} node = walker.nextNode(); } const lines = (contentEl.innerText || contentEl.textContent || '').split('\n'); for (let line of lines) { line = line.trim(); if (line) return line; } return ''; } function applyEarlyEnhanceIslandAutoTitle() { if (!isEnhanceIslandAutoTitlePage()) return false; const titleText = selectEnhanceIslandTitleText(); if (!titleText) return false; const pathBlocks = window.location.pathname.split('/').splice(1); const searchParams = new URLSearchParams(window.location.search || ''); const page = pathBlocks[0] === 'Forum' ? (pathBlocks[5]?.replace(/\.html$/, '') || 1) : (searchParams.get('page') || 1); const titleEl = document.querySelector('title'); if (!titleEl) return false; titleEl.textContent = `${titleText} - ${getEnhanceIslandOriginalTitle()} - page ${page}`; return true; } function installEarlyEnhanceIslandAutoTitle() { if (!isEnhanceIslandAutoTitlePage()) return; let observer = null; let rafId = 0; let stopped = false; const stop = () => { stopped = true; if (rafId) cancelAnimationFrame(rafId); if (observer) observer.disconnect(); observer = null; }; const queueTitleUpdate = () => { if (stopped || rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; if (stopped) return; if (applyEarlyEnhanceIslandAutoTitle()) stop(); }); }; const startObserve = () => { if (stopped) return; const target = document.body || document.documentElement; if (!target || observer) return; observer = new MutationObserver(queueTitleUpdate); observer.observe(target, { childList: true, subtree: true, characterData: true }); queueTitleUpdate(); setTimeout(stop, 5000); }; getEnhanceIslandOriginalTitle(); if (document.body || document.documentElement) { startObserve(); } else { const timer = setInterval(() => { if (document.body || document.documentElement) { clearInterval(timer); startObserve(); } }, 25); setTimeout(() => { clearInterval(timer); stop(); }, 5000); } } function installEarlyStartupObserver() { const relevantSelector = [ '.h-threads-list', '.h-threads-item', '.h-threads-item-replies', '.h-threads-item-reply', '.h-threads-info-title', '.h-threads-info-email', '.h-threads-info-uid', '.h-threads-item-reply-icon', '.h-forum-header', 'ul.uk-pagination.uk-pagination-left.h-pagination', 'form[action="/Home/Forum/doReplyThread.html"]', 'form[action="/Home/Forum/doPostThread.html"]', '#h-report-switch' ].join(','); let observer = null; let rafId = 0; let passCount = 0; let stopped = false; const stop = () => { stopped = true; if (rafId) cancelAnimationFrame(rafId); if (observer) observer.disconnect(); observer = null; }; const queuePass = () => { if (stopped || rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; if (stopped) return; runEarlyStartupPass(document); passCount += 1; if (passCount >= 4) stop(); }); }; const startObserve = () => { if (stopped) return; const target = document.body || document.documentElement; if (!target || observer) return; observer = new MutationObserver(mutations => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; const el = node; if (el.matches?.(relevantSelector) || el.querySelector?.(relevantSelector)) { queuePass(); return; } } } }); observer.observe(target, { childList: true, subtree: true }); setTimeout(stop, 1200); }; runEarlyStartupPass(document); if (document.body || document.documentElement) { startObserve(); } else { const timer = setInterval(() => { if (document.body || document.documentElement) { clearInterval(timer); startObserve(); } }, 25); setTimeout(() => { clearInterval(timer); stop(); }, 1200); } } const startupPerfDebug = (() => { // 现场取证开关:默认关闭(关闭时 record/mark 全是空调用,无额外开销)。 // 开启方式(无需改源码): // localStorage.setItem('xdexPerfCollect','1') 后刷新页面;或控制台执行 __xdexPerfCollectOn() 再刷新 // 关闭:__xdexPerfCollectOff() 后刷新,或 localStorage.removeItem('xdexPerfCollect') let AUTO_COLLECTION_ENABLED = false; try { AUTO_COLLECTION_ENABLED = (typeof localStorage !== 'undefined' && localStorage.getItem('xdexPerfCollect') === '1'); } catch (e) {} try { window.__xdexPerfCollectOn = () => { try { localStorage.setItem('xdexPerfCollect', '1'); } catch (e) {} console.log('[XDEX perf] 采集已开启,请刷新页面后复现操作'); }; window.__xdexPerfCollectOff = () => { try { localStorage.removeItem('xdexPerfCollect'); } catch (e) {} console.log('[XDEX perf] 采集已关闭,请刷新页面'); }; window.__xdexPerfCollectState = () => ({ enabled: AUTO_COLLECTION_ENABLED, report: 'window.__xdexStartupPerfReport()', reset: 'window.__xdexStartupPerfReset()', capture: 'window.__xdexPerfCapture("场景名")', list: 'window.__xdexPerfRounds()', print: 'window.__xdexPerfPrint()', clear: 'window.__xdexPerfClear()' }); } catch (e) {} const LONG_TASK_MS = 50; const LOG_TASK_MS = 16; const AUTO_REPORT_AFTER_MS = 60000; const stats = new Map(); const events = []; const browserEvents = []; const startedAt = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); const target = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; function now() { return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); } function getStat(label) { if (!stats.has(label)) { stats.set(label, { label, count: 0, total: 0, max: 0, longCount: 0, last: 0, meta: null }); } return stats.get(label); } function normalizeMeta(meta) { try { return typeof meta === 'function' ? meta() : (meta || null); } catch (e) { return { metaError: e && e.message ? e.message : String(e) }; } } function record(label, duration, meta) { if (!AUTO_COLLECTION_ENABLED) return; const stat = getStat(label); stat.count += 1; stat.total += duration; stat.max = Math.max(stat.max, duration); stat.last = duration; if (duration >= LONG_TASK_MS) stat.longCount += 1; stat.meta = normalizeMeta(meta); const event = { at: Math.round(now()), label, duration: Number(duration.toFixed(2)), meta: stat.meta }; events.push(event); if (duration >= LOG_TASK_MS) { console.log('[XDEX startup perf]', event); } } function mark(label, meta) { if (!AUTO_COLLECTION_ENABLED) return null; const event = { at: Math.round(now()), label: `mark:${label}`, duration: 0, meta: normalizeMeta(meta) }; events.push(event); if (typeof performance !== 'undefined' && performance.mark) { try { performance.mark(`XDEX:${label}`); } catch (e) {} } return event; } // ★ 零开销快路径:关闭采集时直接转发调用,不读时钟、不构造 meta。 // 热路径(滚动/观察器回调)上的埋点全部依赖这条早退,否则每帧两次 performance.now 会变成常驻成本。 function begin() { return AUTO_COLLECTION_ENABLED ? now() : null; } // 与 begin 配对:token 为 null(未开启采集)时立即返回; // 用 `token == null` 而非 falsy 判断,避免 performance.now() 早期返回 0 被误判为「未计时」 function finish(label, token, meta) { if (!AUTO_COLLECTION_ENABLED || token == null) return; record(label, now() - token, meta); } function measure(label, fn, meta) { if (!AUTO_COLLECTION_ENABLED) return fn(); const started = now(); try { return fn(); } finally { record(label, now() - started, meta); } } async function measureAsync(label, fn, meta) { if (!AUTO_COLLECTION_ENABLED) return fn(); const started = now(); try { return await fn(); } finally { record(label, now() - started, meta); } } function measureObserver(label, mutations, fn, meta) { if (!AUTO_COLLECTION_ENABLED) return fn(); let result; const started = now(); try { result = fn(); return result; } finally { let added = 0; let removed = 0; for (const mutation of mutations || []) { added += mutation.addedNodes ? mutation.addedNodes.length : 0; removed += mutation.removedNodes ? mutation.removedNodes.length : 0; } record(`observer:${label}`, now() - started, () => Object.assign({ records: mutations ? mutations.length : 0, added, removed }, normalizeMeta(meta) || {})); } } function summarizeRoot(root) { const scope = root && root.querySelectorAll ? root : document; if (!scope || !scope.querySelectorAll) return { root: 'unknown' }; return { root: root === document ? 'document' : (root && root.className ? String(root.className).slice(0, 80) : root && root.nodeName), threads: scope.querySelectorAll('.h-threads-content').length, replies: scope.querySelectorAll('.h-threads-item-reply-main').length, imgs: scope.querySelectorAll('img').length, previewBoxes: scope.querySelectorAll('.h-preview-box').length }; } function recordObserver(label, mutations, meta) { let added = 0; let removed = 0; for (const mutation of mutations || []) { added += mutation.addedNodes ? mutation.addedNodes.length : 0; removed += mutation.removedNodes ? mutation.removedNodes.length : 0; } record(`observer:${label}`, 0, () => Object.assign({ records: mutations ? mutations.length : 0, added, removed }, normalizeMeta(meta) || {})); } function report(options = {}) { const table = Array.from(stats.values()) .map(item => ({ label: item.label, count: item.count, total: Number(item.total.toFixed(2)), max: Number(item.max.toFixed(2)), avg: Number((item.total / Math.max(1, item.count)).toFixed(2)), longCount: item.longCount, last: Number(item.last.toFixed(2)), meta: item.meta })) .sort((a, b) => b.total - a.total || b.max - a.max); const elapsed = Number((now() - startedAt).toFixed(2)); if (options.table !== false) console.table(table); const result = { elapsed, autoWindowMs: AUTO_REPORT_AFTER_MS, stats: table, events: events.slice(-300), browserEvents: browserEvents.slice(-200) }; console.log(options.auto ? '[XDEX startup perf auto report]' : '[XDEX startup perf report]', result); return result; } function reset() { stats.clear(); events.length = 0; browserEvents.length = 0; console.log('[XDEX startup perf] reset'); return 'reset'; } // ==================== 场景记录与导出(真机测量用) ==================== // 设计:每轮测量 = 一次 reset → 一次操作 → 一次 capture(name) 留档。 // 留档只做「归档 + 精简」,不改变统计口径;导出的 JSON 用于跨版本/跨场景对照。 const rounds = []; // 只保留判读所需的字段,避免导出体积随 events/browserEvents 膨胀 const MEASURED_LABELS = [ 'hdLazy.onScrollFrame', 'hdLazy.measureQueue', // D:滚动帧内成本 'seamless.runUserScrollFrame', // D:无缝翻页判定 'applyPageEnhancements.total', 'applyPageEnhancements.syncPreprocess', // A/B/C/D:增强收口 'refresh.checkNext.syncDom', // C:刷新按钮 / 末页旁路 'refresh.postReply.syncDom', // A:串内页发送后刷新 // B:板块页/时间线发送后增量刷新(Step 5-8 的同步 DOM 工作与两处全文档调用) 'boardQuickReply.total', 'boardQuickReply.step5.measure', 'boardQuickReply.step6.insert', 'boardQuickReply.step7.compensate', 'boardQuickReply.step8.post', 'boardQuickReply.buildNode', 'boardQuickReply.deferred.refreshFilter', 'boardQuickReply.deferred.enablePostExpand', 'boardQuickReply.deferred.total', // B:await 分段(区分「网络耗时」与「桥接阻塞」——整链 7.5s 而同步 DOM 仅 ~28ms) 'boardQuickReply.await.p1', 'boardQuickReply.await.tail', 'boardQuickReply.await.prev', 'refreshFilterDisplay', 'applyFilters', // 过滤(作用域是否放大) 'seamless.loadNext.total', 'seamless.loadNext.applyPageEnhancements', 'seamless.loadNextBoard.total', 'seamless.loadNextBoard.applyPageEnhancements', 'enableHDImageAndLayoutFix', 'enableHDImageAndLayoutFix.pending', 'highlightPO', 'initContent' ]; function collectScenario(round) { const observers = []; let observerTotal = 0; round.stats.forEach(s => { if (s.label.indexOf('observer.create:') === 0) { observers.push({ name: s.label.slice('observer.create:'.length), count: s.count }); observerTotal += s.count; } }); const frames = round.browserEvents.filter(e => e.label === 'browser:long-animation-frame'); const blocking = frames.reduce((a, e) => a + (e.blockingDuration || 0), 0); // ★ 长帧归因汇总:按脚本来源聚合 duration 与 forcedStyleAndLayout, // 定位「未归因阻塞」属于哪个脚本/函数(LoAF scripts[] 提供) const scriptAgg = new Map(); frames.forEach(f => (f.scripts || []).forEach(s => { const key = `${s.src || '?'}::${s.fn || s.invoker || '?'}`; const acc = scriptAgg.get(key) || { src: s.src, fn: s.fn, invoker: s.invoker, duration: 0, forcedStyleAndLayout: 0, times: 0 }; acc.duration += s.duration || 0; acc.forcedStyleAndLayout += s.forcedStyleAndLayout || 0; acc.times += 1; scriptAgg.set(key, acc); })); const topScripts = Array.from(scriptAgg.values()) .map(s => ({ ...s, duration: Number(s.duration.toFixed(2)), forcedStyleAndLayout: Number(s.forcedStyleAndLayout.toFixed(2)) })) .sort((a, b) => (b.duration + b.forcedStyleAndLayout) - (a.duration + a.forcedStyleAndLayout)) .slice(0, 12); return { // ★ 关键字段前置:控制台复制时容易从尾部截断,而 blocking/LoAF 归因是排障的核心依据 scenario: round.scenario, blockingDurationMs: Number(blocking.toFixed(2)), longAnimationFrames: frames.length, longTasks: round.browserEvents.filter(e => e.label === 'browser:longtask').length, topLoafScripts: topScripts, observerCreates: observers, observerCreateTotal: observerTotal, at: new Date(round.at).toISOString(), version: round.version, page: round.page, elapsedMs: round.elapsed, measured: round.stats.filter(s => MEASURED_LABELS.indexOf(s.label) !== -1), allLabels: round.stats.map(s => ({ label: s.label, count: s.count, total: s.total, max: s.max })) }; } // capture(name):把当前累计统计按场景名留档并清空,返回该场景的精简摘要 function capture(name) { if (!name) { console.warn('[XDEX perf] capture(name) 需要场景名,例如 capture("D-scroll")'); return null; } const round = { scenario: name, at: Date.now(), version: (typeof VERSION !== 'undefined' ? VERSION : 'unknown'), page: location.pathname + location.search, stats: report({ table: false }).stats, elapsed: Number((now() - startedAt).toFixed(2)), browserEvents: browserEvents.slice() }; const scenario = collectScenario(round); rounds.push(scenario); reset(); console.log(`[XDEX perf] 已留档「${name}」:${scenario.measured.length} 个测量标签,` + `${scenario.observerCreateTotal} 个观察器创建,${scenario.longAnimationFrames} 个长帧`); // ★ 单行摘要:控制台复制大对象时常被截断,这里把判读所需的三个数压缩成一行 const hot = scenario.measured.slice(0, 3) .map(m => `${m.label.replace(/^boardQuickReply\./, '')}=${m.total}`).join(' '); console.log(`[XDEX perf] 摘要 blocking=${scenario.blockingDurationMs}ms 长帧=${scenario.longAnimationFrames}` + ` 观察器=${scenario.observerCreateTotal} | ${hot}`); if (scenario.topLoafScripts.length) { console.log('[XDEX perf] 长帧归因 TOP(duration+forcedLayout,含强制布局):'); console.table(scenario.topLoafScripts.map(s => ({ src: s.src, fn: s.fn || s.invoker, times: s.times, duration: s.duration, forcedLayout: s.forcedStyleAndLayout }))); } console.table(scenario.measured.map(m => ({ label: m.label, count: m.count, total: m.total, max: m.max }))); return scenario; } // export():返回全部留档的 JSON 字符串(便于整段复制);dumps 保留挂到 window 供 DevTools copy() function exportAll() { const payload = { exportedAt: new Date().toISOString(), userAgent: (typeof navigator !== 'undefined' && navigator.userAgent) || '', viewport: (typeof window !== 'undefined') ? { w: window.innerWidth, h: window.innerHeight } : null, rounds }; const json = JSON.stringify(payload, null, 2); try { window.__xdexPerfDump = json; } catch (e) {} return json; } // print():把 JSON 直接展开输出到控制台(剪贴板 API 需用户手势/受页面策略限制,不可靠,故不采用)。 // 同时输出对象本体:DevTools 里可右键「Copy object」拿到结构化副本。 function printAll() { const json = exportAll(); console.log('[XDEX perf] 导出 JSON(%d bytes,可直接从下方选中复制):', json.length); console.log(json); console.log('[XDEX perf] 结构化对象(右键可 Copy object):'); console.log(JSON.parse(json)); return json; } function listRounds() { console.table(rounds.map(r => ({ scenario: r.scenario, version: r.version, page: r.page, measured: r.measured.length, observers: r.observerCreateTotal, longFrames: r.longAnimationFrames, blockingMs: r.blockingDurationMs }))); return rounds; } function clearRounds() { rounds.length = 0; console.log('[XDEX perf] 留档已清空'); return 'cleared'; } function installBrowserObservers() { if (typeof PerformanceObserver === 'undefined' || !PerformanceObserver.supportedEntryTypes) return; const supported = PerformanceObserver.supportedEntryTypes; if (supported.includes('longtask')) { try { const observer = new PerformanceObserver(list => { list.getEntries().forEach(entry => { const item = { at: Math.round(entry.startTime), label: 'browser:longtask', duration: Number(entry.duration.toFixed(2)), name: entry.name || '', attribution: Array.from(entry.attribution || []).map(attr => ({ name: attr.name || '', containerType: attr.containerType || '', containerName: attr.containerName || '', containerSrc: attr.containerSrc || '' })) }; browserEvents.push(item); console.log('[XDEX browser perf]', item); }); }); observer.observe({ entryTypes: ['longtask'] }); } catch (e) {} } if (supported.includes('long-animation-frame')) { try { const observer = new PerformanceObserver(list => { list.getEntries().forEach(entry => { const item = { at: Math.round(entry.startTime), label: 'browser:long-animation-frame', duration: Number(entry.duration.toFixed(2)), renderStart: entry.renderStart ? Number(entry.renderStart.toFixed(2)) : 0, styleAndLayoutStart: entry.styleAndLayoutStart ? Number(entry.styleAndLayoutStart.toFixed(2)) : 0, blockingDuration: entry.blockingDuration ? Number(entry.blockingDuration.toFixed(2)) : 0, // ★ 归因:LoAF 的 scripts[] 给出每段脚本的 URL/入口函数与其中「强制样式与布局」耗时。 // 590ms 级未归因阻塞靠它定位,比逐个加埋点快。 scripts: Array.from(entry.scripts || []).map(s => ({ src: String(s.sourceURL || '').split('/').pop() || '(inline)', fn: String(s.sourceFunctionName || ''), invoker: String(s.invoker || ''), duration: Number((s.duration || 0).toFixed(2)), forcedStyleAndLayout: Number((s.forcedStyleAndLayoutDuration || 0).toFixed(2)), pause: Number((s.pauseDuration || 0).toFixed(2)) })).sort((a, b) => (b.duration + b.forcedStyleAndLayout) - (a.duration + a.forcedStyleAndLayout)) }; browserEvents.push(item); console.log('[XDEX browser perf]', item); }); }); observer.observe({ type: 'long-animation-frame', buffered: true }); } catch (e) {} } } const api = { measure, measureAsync, measureObserver, begin, finish, record, recordObserver, mark, report, reset, summarizeRoot, capture, exportAll, printAll, listRounds, clearRounds }; window.__xdexStartupPerfReport = report; window.__xdexStartupPerfReset = reset; target.__xdexStartupPerfReport = report; target.__xdexStartupPerfReset = reset; // 真机测量入口:capture(场景名) 留档 / listRounds() 查看 / print() 展开输出 window.__xdexPerfCapture = capture; window.__xdexPerfRounds = listRounds; window.__xdexPerfExport = exportAll; window.__xdexPerfPrint = printAll; window.__xdexPerfClear = clearRounds; target.__xdexPerfCapture = capture; target.__xdexPerfRounds = listRounds; target.__xdexPerfExport = exportAll; target.__xdexPerfPrint = printAll; target.__xdexPerfClear = clearRounds; if (AUTO_COLLECTION_ENABLED) { installBrowserObservers(); setTimeout(() => { report({ auto: true }); }, AUTO_REPORT_AFTER_MS); console.log('[XDEX startup perf] auto collection started; auto report in 60s'); } return api; })(); function deferStartupTask(task, delay = 0, label = 'anonymous') { const run = () => { try { startupPerfDebug.measure(`deferStartupTask:${label}`, task, { delay }); } catch (e) { console.warn('启动延迟任务失败', e); } }; const schedule = () => setTimeout(run, delay); if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(schedule); } else { schedule(); } } function deferStartupSteps(steps, delay = 0, label = 'anonymous') { const list = Array.isArray(steps) ? steps.filter(step => step && typeof step.run === 'function') : []; if (!list.length) return; const runStep = (index) => { if (index >= list.length) return; const step = list[index]; startupPerfDebug.mark(`deferStartupSteps:${label}:${index + 1}:start`, { delay, index, step: step.label || '' }); try { startupPerfDebug.measure(step.label || `deferStartupSteps:${label}:${index + 1}`, step.run, step.meta || { delay, index }); } catch (e) { console.warn('启动分片任务失败', step.label || label, e); } finally { startupPerfDebug.mark(`deferStartupSteps:${label}:${index + 1}:end`, { delay, index, step: step.label || '' }); } setTimeout(() => runStep(index + 1), 0); }; const run = () => runStep(0); const schedule = () => setTimeout(run, delay); if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(schedule); } else { schedule(); } } function addLastPageNumber(){ document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination a').forEach(a => { if (a.textContent.trim() === '末页') { const m = a.href.match(/page=(\d+)/); if (m && !a.textContent.includes(`(${m[1]})`)) { a.textContent = `末页(${m[1]})`; } } }); } // 自动监听 DOM 变化 function observePagination(){ if (observePagination.__bound) return; const observeTarget = document.body || document.documentElement; if (!observeTarget) { setTimeout(observePagination, 25); return; } observePagination.__bound = true; const observer = new MutationObserver(mutations => { let foundPagination = false; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; // 只处理元素节点 // 如果新增的是分页条本身,或它的子元素 if ( node.matches?.('ul.uk-pagination.uk-pagination-left.h-pagination') || node.closest?.('ul.uk-pagination.uk-pagination-left.h-pagination') ) { foundPagination = true; break; } } if (foundPagination) break; // 已找到就不再继续遍历 } if (foundPagination) { addLastPageNumber(); } }); observer.observe(observeTarget, { childList: true, subtree: true }); } // 初始化 //duplicatePagination(); observePagination(); /* -------------------------------------------------- * tag 7. 自动+手动无缝翻页 * -------------------------------------------------- */ // ========== 公共增强函数 ========== // root: 新插入或替换的 DOM 节点(例如 repliesClone 或 targetReplies) // cfg: 当前配置对象 function getPageEnhancementConfig(cfg) { try { // 关键:以 GM 最新配置为最高优先级,避免旧 cfg 覆盖新设置 return Object.assign({}, SettingPanel.defaults, cfg || {}, GM_getValue(SettingPanel.key, {})); } catch (e) { return Object.assign({}, SettingPanel.defaults, cfg || {}); } } // 插入 DOM 前可安全执行的同步增强:避免用户看到原始 DOM → 增强后 DOM 的闪烁过程 function preprocessPageEnhancementsBeforeInsert(root, cfg) { const liveCfg = getPageEnhancementConfig(cfg); try { if (typeof runCriticalVisualEnhancements === 'function') runCriticalVisualEnhancements(root, liveCfg); } catch (e) {} try { if (liveCfg && liveCfg.enableAutoUrlLinkify && typeof runAutoUrlLinkify === 'function') runAutoUrlLinkify(root); } catch (e) {} try { if (liveCfg && liveCfg.extendQuote && typeof extendQuote === 'function') extendQuote(root); } catch (e) {} } function applyPageEnhancements(root, cfg, hotNodes) { const getLatestCfg = () => getPageEnhancementConfig(cfg); let liveCfg = getLatestCfg(); // ★ 同步段埋点:插入前的预增强(URL 链接化 / 引用扩展)属同步成本,与下面的异步段分开计量 startupPerfDebug.measure('applyPageEnhancements.syncPreprocess', () => preprocessPageEnhancementsBeforeInsert(root, liveCfg), { root: root === document ? 'document' : 'scoped' }); setTimeout(() => { // ★ 埋点覆盖全部 4 个调用点(板块增量刷新 / 快速回复 / 无缝翻页两种): // 本函数体是 setTimeout 异步,外层 measure 只能量到同步的 preprocess 部分(数十微秒), // 真正耗时在这段回调里,故计时点必须放在回调内部。 const __perfToken = startupPerfDebug.begin(); liveCfg = getLatestCfg(); try { if (typeof hideEmptyTitleAndEmail === 'function') hideEmptyTitleAndEmail($(root)); } catch (e) {} // 只处理本次新增内容(root=新克隆/目标回复区),避免无缝翻页后对全页既有内容重复编号与重标 Po try { if (typeof highlightPO === 'function') highlightPO(root); } catch (e) {} // ★ HD 两函数是布局读写热点(handleGeneralElements 对每元素 读布局+写样式 交错强制重排)。 // root 为「含旧回复的容器」(发送后/手动刷新路径)时必须限定本次新增节点(hotNodes), // 否则旧回复全部重跑 → 长串数百次强制重排 = 插入后卡顿主源。 // root 为纯新内容(无缝翻页克隆)时 hotNodes 为空 → 保持原行为跑 root。 if (Array.isArray(hotNodes) && hotNodes.length) { for (const hot of hotNodes) { const hotScope = hot.closest && hot.closest('.h-threads-item-reply') || hot; try { if (liveCfg && liveCfg.enableHDImageAndLayoutFix && typeof enableHDImageAndLayoutFix === 'function') enableHDImageAndLayoutFix(hotScope); } catch (e) {} try { if (liveCfg && liveCfg.enableHDImage && typeof enableHDImage === 'function') enableHDImage(hotScope); } catch (e) {} } } else { try { if (liveCfg && liveCfg.enableHDImageAndLayoutFix && typeof enableHDImageAndLayoutFix === 'function') enableHDImageAndLayoutFix(root); } catch (e) {} try { if (liveCfg && liveCfg.enableHDImage && typeof enableHDImage === 'function') enableHDImage(root); } catch (e) {} } try { if (typeof applyImageHideMode === 'function') { if (liveCfg && liveCfg.enableImageHideMode) { applyImageHideMode(liveCfg.applyImageHideMode || 'default', root); } else { applyImageHideMode('default', root); } } } catch (e) {} try { if (liveCfg && liveCfg.enableLinkBlank && typeof runLinkBlank === 'function') runLinkBlank(root); } catch (e) {} try { if (liveCfg && liveCfg.enableAutoUrlLinkify && typeof runAutoUrlLinkify === 'function') runAutoUrlLinkify(root); } catch (e) {} try { if (liveCfg && liveCfg.extendQuote && typeof extendQuote === 'function') extendQuote(root); } catch (e) {} try { if (liveCfg && liveCfg.enableQuotePreview && typeof enableQuotePreview === 'function') enableQuotePreview(); } catch (e) {} // 作用域必须与上方各步骤一致传 root:传 document 会在每次局部刷新/快速回复/翻页时 // 对整条串的全部回复重跑「重置PO布局 → 过滤 → 重算PO布局」,成本随串长度线性放大 try { refreshFilterDisplay(liveCfg, root); } catch (e) {} try { if (liveCfg && liveCfg.enableRelativeTime && typeof formatDateStrOnPage === 'function') formatDateStrOnPage(root); } catch (e) {} try { if (typeof initContent === 'function') initContent(root); } catch (e) {} //try { if (typeof autoHideRefView === 'function') autoHideRefView(root); } catch (e) {} try { if (typeof enablePostExpand === 'function') enablePostExpand(root); } catch (e) {} // 板块页/时间线串卡片「获取最新回复」按钮:与 enablePostExpand 同为逐卡片注入,必须随每次追加重应用 // (原先只有启动时两处一次性 document 扫描,导致无缝翻页新增卡片无按钮) // 置于 enablePostExpand 之后,使 .js-toggle-mode 已存在、本按钮可正确排到其左侧 try { if (typeof bindBoardThreadRefreshButtons === 'function') bindBoardThreadRefreshButtons(root); } catch (e) {} // if (typeof preventContentOverflow === 'function') { // try { preventContentOverflow(document); } catch (e) {} // } // ★ meta 带 root 标识与回复数:用于判断成本是否随串长/DOM 规模放大(R2/R3/R4 的核心指标) startupPerfDebug.finish('applyPageEnhancements.total', __perfToken, () => { const el = root && root.nodeType === 1 ? root : null; return { root: el ? (el.className ? String(el.className).slice(0, 60) : el.nodeName) : 'none', replies: el && el.querySelectorAll ? el.querySelectorAll('.h-threads-item-reply-main').length : -1, doc: root === document }; }); }, 50); } function isEnhanceIslandAutoTitlePage() { return PageType.isThreadPage(); } function refreshEnhanceIslandAutoTitle() { if (!isEnhanceIslandAutoTitlePage()) return; try { if (typeof window.enhanceIslandAutoTitle === 'function') { window.enhanceIslandAutoTitle(); } } catch (e) { console.warn('[enhanceIslandAutoTitle] refresh failed:', e); } } // ========== 无缝翻页 / 发帖刷新共用的无状态工具层(Phase1:从 initSeamlessPaging 闭包抽出) ========== // 不依赖 loading/loadedPages 等状态机变量;签名与选择器保持与旧内联实现一致。 // 调试 log 默认关闭:localStorage.xdexSeamlessDebug = '1' 或 window.__XDEX_SEAMLESS_DEBUG = true function isSeamlessDebugEnabled() { try { if (typeof window !== 'undefined' && window.__XDEX_SEAMLESS_DEBUG) return true; if (typeof localStorage !== 'undefined' && localStorage.getItem('xdexSeamlessDebug') === '1') return true; } catch (e) {} return false; } function seamlessDebugLog() { if (!isSeamlessDebugEnabled()) return; try { console.log.apply(console, arguments); } catch (e) {} } function getRealThreadsList(root = document) { const lists = Array.from((root || document).querySelectorAll('.h-threads-list')); return lists.find(el => !el.closest('.h-preview-box')) || null; } // 获取 DOM 中最大的 data-cloned-page(已被无缝加载进来的最大页) function getMaxClonedPageInDOM() { let max = 0; document.querySelectorAll('.h-threads-item-replies[data-cloned-page]').forEach(el => { const n = parseInt(el.getAttribute('data-cloned-page'), 10); if (!isNaN(n) && n > max) max = n; }); return max; } function removeIdsFromNode(node) { if (!node || node.querySelectorAll === undefined) return; node.querySelectorAll('[id]').forEach(el => el.removeAttribute('id')); if (node.hasAttribute && node.hasAttribute('id')) node.removeAttribute('id'); } function parseLastPageFromPagination(pagUl) { if (!pagUl) return null; const anchors = Array.from(pagUl.querySelectorAll('a, span')).map(a => a.href || a.getAttribute('href') || ''); const pageNums = anchors.map(h => { if (typeof parsePaginationPageNum === 'function') return parsePaginationPageNum(h); const m = (h || '').match(/[?&]page=(\d+)|\/page\/(\d+)\.html/); return m ? Number(m[1] || m[2]) : null; }).filter(n => !!n); if (pageNums.length === 0) return null; return Math.max(...pageNums); } function getDomLastPageNum() { const allPaginations = document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); if (allPaginations.length === 0) return null; const lastPagination = allPaginations[allPaginations.length - 1]; return parseLastPageFromPagination(lastPagination); } function buildThreadPageUrl(threadId, pageNum) { // 如果当前是 /Forum/po/id/{threadId}/page/N.html 形式 if (PageType.isPOThreadPage()) { return `${location.origin}/Forum/po/id/${threadId}/page/${pageNum}.html`; } // 默认 /t/{threadId}?page=N return `${location.origin}/t/${threadId}?page=${pageNum}`; } function extractFromHTML(htmlText) { const doc = new DOMParser().parseFromString(htmlText, 'text/html'); const repliesAll = doc.querySelectorAll('.h-threads-item-replies'); const replies = repliesAll.length ? repliesAll[0] : doc.querySelector('.h-threads-item-replies'); let pagination = doc.querySelector('ul.uk-pagination.uk-pagination-left.h-pagination') || doc.querySelector('ul.uk-pagination.uk-pagination-left') || doc.querySelector('ul.uk-pagination'); return { replies, pagination, doc }; } // ========== Phase2:串内增量刷新共享核(无缝侧/发帖侧共用) ========== // 只承载:定目标页 / 建回复容器 / 按 id 增量 append / 同步底部分页。 // 收尾判定(last/hasNext vs wasLastPage 涨页)仍由两边包装负责。 function resolveThreadRefreshTargetPage(maxPage, maxCloned, pageCursor) { if ((maxPage && maxCloned === maxPage && maxCloned > 0) || (!maxPage && maxCloned > 0)) { return { kind: 'cloned', targetPage: maxCloned }; } if (maxPage && pageCursor === maxPage && maxCloned === 0) { return { kind: 'main', targetPage: null }; } return { kind: 'skip', targetPage: null }; } function ensureThreadRepliesContainer(list, clonedPage) { const pageKey = clonedPage && clonedPage > 0 ? clonedPage : 0; let targetReplies = pageKey > 0 ? list.querySelector('.h-threads-item-replies[data-cloned-page="' + pageKey + '"]') : list.querySelector('.h-threads-item-replies:not([data-cloned-page])'); if (targetReplies) return { targetReplies: targetReplies, created: false }; const threadItem = list.querySelector('.h-threads-item'); if (!threadItem) return { targetReplies: null, created: false }; targetReplies = document.createElement('div'); targetReplies.className = 'h-threads-item-replies'; if (pageKey > 0) targetReplies.setAttribute('data-cloned-page', String(pageKey)); threadItem.appendChild(targetReplies); return { targetReplies: targetReplies, created: true }; } function stripSystemTipReplies(root) { if (!root || !root.querySelectorAll) return; try { root.querySelectorAll('.h-threads-item-reply[data-threads-id="9999999"]').forEach(function (n) { n.remove(); }); } catch (e) {} } // 按 data-threads-id 增量 append;返回 hasUpdate 与新节点列表 function appendMissingRepliesByThreadsId(targetReplies, sourceRoot, options) { options = options || {}; const replyOnly = options.replyOnly === true; const excludeSystemOnOld = options.excludeSystemOnOld === true; const excludeSystemOnNew = options.excludeSystemOnNew !== false; const sel = replyOnly ? '.h-threads-item-reply[data-threads-id]' : '[data-threads-id]'; const oldItems = Array.from(targetReplies.querySelectorAll(sel)); const oldIdSet = new Set(); oldItems.forEach(function (item) { const id = item.getAttribute('data-threads-id') || (item.dataset && item.dataset.threadsId); if (!id) return; if (excludeSystemOnOld && id === '9999999') return; oldIdSet.add(id); }); let newItems = Array.from(sourceRoot.querySelectorAll(sel)); if (excludeSystemOnNew) { newItems = newItems.filter(function (item) { const id = item.getAttribute('data-threads-id') || (item.dataset && item.dataset.threadsId); return id && id !== '9999999'; }); } const appendedNodes = []; let hasUpdate = false; for (let i = 0; i < newItems.length; i++) { const item = newItems[i]; const tid = item.getAttribute('data-threads-id') || (item.dataset && item.dataset.threadsId); if (!tid || oldIdSet.has(tid)) continue; hasUpdate = true; const node = item.cloneNode(true); targetReplies.appendChild(node); appendedNodes.push(node); oldIdSet.add(tid); } return { hasUpdate: hasUpdate, appendedNodes: appendedNodes }; } function prepareAndSyncBottomPagination(doc) { const newPags = doc.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); const newPag = newPags.length ? newPags[newPags.length - 1] : null; const oldPags = document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); const oldPag = oldPags.length ? oldPags[oldPags.length - 1] : null; if (newPag && oldPag) { const preparedPag = newPag.cloneNode(true); try { if (typeof rebuildPaginationPages === 'function') rebuildPaginationPages(preparedPag); } catch (e) {} try { if (typeof processPagination === 'function') processPagination(preparedPag); } catch (e) {} try { oldPag.innerHTML = preparedPag.innerHTML; } catch (e) { oldPag.replaceWith(preparedPag); } } return { newPag: newPag, oldPag: oldPag }; } // ========== /Phase2 增量刷新共享核 ========== // ========== /无状态工具层 ========== function initSeamlessPaging() { let lastCheckAt = 0; // 所有需要被 window.SeamlessPaging 访问的变量都在此声明 let loading = false; let forceLoadPending = false; // 手动重载 50ms 定时器待执行标记,防网络慢时重复触发 let done = false; let loadedPages = new Set(); let reachedLastPageAt = -1; let lastFinalToastTs = 0; let lastLoadedPage = 1; let observer = null; let observerFrozen = false; let hasUserInteracted = false; let lastUserScrollDir = 0; let lastScrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; // Phase1:发现新页后的状态回退唯一入口(刷新按钮 / 末页 hasNext / SeamlessPaging.loadNext 共用) // 语义保持:delete 目标页、loading=false、可选回退 lastLoadedPage、lastCheckAt=0 function prepareForceLoadNext(nextPage) { if (nextPage != null && nextPage !== undefined) { loadedPages.delete(nextPage); lastLoadedPage = nextPage - 1; } else { loadedPages.delete(lastLoadedPage + 1); } loading = false; lastCheckAt = 0; } try { const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); if (!cfg.enableSeamlessPaging) return; // let loading = false; // let done = false; // const loadedPages = new Set(); // let reachedLastPageAt = -1; // 记录“最后一页已加载”的页码(例如 20) // let lastFinalToastTs = 0; // 防抖:末页提示的时间戳,避免重复弹 const isThreadPage = PageType.isThreadPage(); const isBoardPage = PageType.isBoardOrTimelinePage(); const originInfo = (function () { const threadMatch = PageType.getThreadId(); return { origin: location.origin, threadId: threadMatch || (document.querySelector('[data-threads-id]')?.getAttribute('data-threads-id') || null), page: PageType.getPageNum() }; })(); // let lastLoadedPage = originInfo.page || 1; lastLoadedPage = originInfo.page || 1; loadedPages.add(lastLoadedPage); const SENTINEL_ID = 'hld_auto_page_sentinel'; let sentinel = document.getElementById(SENTINEL_ID); // let observer = null; // 新增:让 observer 可被其他函数控制 // let observerFrozen = false; // 新增:哨兵冻结标记(大图激活时用) // 交互状态检测 // let hasUserInteracted = false; // let lastUserScrollDir = 0; // let lastScrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; // Phase3-2:串内/板块自动观察器去重(滚动意图 / 视口判定 / observer 绑定) function isSeamlessNearBottom(marginPx) { const margin = (marginPx == null) ? 200 : marginPx; return (window.scrollY + window.innerHeight) >= (document.documentElement.scrollHeight - margin); } function isSeamlessElementInViewport(el) { if (!el) return false; const r = el.getBoundingClientRect(); return (r.bottom > 0 && r.top < window.innerHeight); } function getSeamlessLastRepliesActiveImageBox() { const container = getRootRepliesContainer(); return container && container.lastReplies ? container.lastReplies.querySelector('.h-threads-img-box.h-active') : null; } // 共用触发门:相交 + 非 loading + 用户向下滚动意图;extraGate===false 时额外拦截(板块 done) function shouldTriggerSeamlessAutoLoad(entry, extraGate) { if (!entry || !entry.isIntersecting || loading) return false; if (extraGate === false) return false; return !!(hasUserInteracted && lastUserScrollDir > 0); } function bindSeamlessIntersectionObserver(placeSentinel, onIntersect) { placeSentinel(); if (!sentinel) return null; const obs = new IntersectionObserver((entries) => { entries.forEach(entry => { onIntersect(entry); }); }, { root: null, rootMargin: '0px', threshold: 0.05 }); obs.observe(sentinel); return obs; } // ★ 滚动高频:本回调每次都读 scrollTop/scrollHeight,未合并会让滚动期间每帧重复强制布局。 // 与同文件 bindScrollListener 的写法对齐:一帧只处理一次,滚动中不重复读取布局。 let userScrollFrameId = 0; function onUserScroll() { if (userScrollFrameId) return; if (typeof requestAnimationFrame !== 'function') { runUserScrollFrame(); return; } userScrollFrameId = requestAnimationFrame(() => { userScrollFrameId = 0; runUserScrollFrame(); }); } function runUserScrollFrame() { const __perfToken = startupPerfDebug.begin(); hasUserInteracted = true; const curTop = window.pageYOffset || document.documentElement.scrollTop || 0; lastUserScrollDir = (curTop > lastScrollTop) ? 1 : (curTop < lastScrollTop ? -1 : lastUserScrollDir); lastScrollTop = curTop; // 新增:若处于冻结状态,满足条件则解冻并恢复观察 if (observerFrozen && sentinel) { const maxDomLast = getDomLastPageNum(); const atLastPage = !!(maxDomLast && lastLoadedPage >= maxDomLast); const nearBottom = isSeamlessNearBottom(); // 若不再有激活大图,或已接近底部(两者满足任一),恢复观察 // 注意:此处仍用全局 querySelector(与旧逻辑一致,不限最后 replies) const activeInView = isSeamlessElementInViewport(document.querySelector('.h-threads-img-box.h-active')); if (!activeInView || (atLastPage && nearBottom)) { try { observer.observe(sentinel); } catch (e) {} observerFrozen = false; } } // ★ 热路径埋点:滚动帧内成本 + 触发次数(frozen 短路时耗时≈0,靠 count 识别) startupPerfDebug.finish('seamless.runUserScrollFrame', __perfToken, () => ({ frozen: !!observerFrozen })); } function onWheel(e) { hasUserInteracted = true; if (typeof e.deltaY === 'number') lastUserScrollDir = e.deltaY > 0 ? 1 : -1; } window.addEventListener('scroll', onUserScroll, { passive: true }); window.addEventListener('wheel', onWheel, { passive: true }); // ======== 新增:桥接器 + 自动重执行器 ======== function reinitForNewContent(container) { try { // 1. 派发自定义事件 document.dispatchEvent(new CustomEvent('SeamlessPageAppended', { detail: { container } })); // 2. 模拟 DOMContentLoaded(部分脚本只监听这个) document.dispatchEvent(new Event('DOMContentLoaded')); // 3. 重新执行 container 内的 <script> 标签 container.querySelectorAll('script').forEach(oldScript => { const newScript = document.createElement('script'); if (oldScript.src) { newScript.src = oldScript.src; } else { newScript.textContent = oldScript.textContent; } [...oldScript.attributes].forEach(attr => { newScript.setAttribute(attr.name, attr.value); }); oldScript.replaceWith(newScript); }); } catch (err) { console.warn('reinitForNewContent error:', err); } } // ============================================ // 串内页容器 function getRootRepliesContainer() { const root = document.querySelector('.h-threads-item.uk-clearfix[data-threads-id]') || document.querySelector('.h-threads-item.uk-clearfix') || document.querySelector('[data-threads-id]'); if (!root) return null; const replies = root.querySelectorAll('.h-threads-item-replies'); if (!replies || replies.length === 0) return null; return { root, lastReplies: replies[replies.length - 1] }; } // 哨兵节点创建:串页/板块页共用,避免两处复制样式 function ensureSentinelNode() { if (!sentinel) { sentinel = document.createElement('div'); sentinel.id = SENTINEL_ID; sentinel.style.height = '1px'; sentinel.style.width = '100%'; sentinel.style.pointerEvents = 'none'; } return sentinel; } function placeSentinelAfter(anchor) { if (!anchor || !anchor.parentNode) return; ensureSentinelNode(); if (anchor.nextSibling !== sentinel) { anchor.parentNode.insertBefore(sentinel, anchor.nextSibling); } } function ensureSentinelPlaced() { const containers = getRootRepliesContainer(); if (!containers) return; // 旧:内联创建 sentinel 节点 // if (!sentinel) { ... } placeSentinelAfter(containers.lastReplies); } // 板块页容器 function ensureSentinelPlacedBoard() { const lists = document.querySelectorAll('.h-threads-list'); const lastList = lists[lists.length - 1]; if (!lastList) return; // 旧:内联创建 sentinel 节点(与串页重复) // if (!sentinel) { ... } placeSentinelAfter(lastList); } // Phase3-4:串内/板块哨兵放置对称入口(mode 策略;不改变放置目标) function ensureSeamlessSentinel(mode) { if (mode === 'board') ensureSentinelPlacedBoard(); else ensureSentinelPlaced(); // thread(默认) } // Phase1:以下无状态工具已上提到闭包外共用实现;旧内联定义保留注释,避免重复声明遮盖外层函数 // function removeIdsFromNode(node) { // if (!node || node.querySelectorAll === undefined) return; // node.querySelectorAll('[id]').forEach(el => el.removeAttribute('id')); // if (node.hasAttribute && node.hasAttribute('id')) node.removeAttribute('id'); // } // function parseLastPageFromPagination(pagUl) { // if (!pagUl) return null; // const anchors = Array.from(pagUl.querySelectorAll('a, span')).map(a => a.href || a.getAttribute('href') || ''); // const pageNums = anchors.map(h => { // if (typeof parsePaginationPageNum === 'function') return parsePaginationPageNum(h); // const m = (h || '').match(/[?&]page=(\d+)|\/page\/(\d+)\.html/); // return m ? Number(m[1] || m[2]) : null; // }).filter(n => !!n); // if (pageNums.length === 0) return null; // return Math.max(...pageNums); // } // function getDomLastPageNum() { // const allPaginations = document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); // if (allPaginations.length === 0) return null; // const lastPagination = allPaginations[allPaginations.length - 1]; // return parseLastPageFromPagination(lastPagination); // } // function buildThreadPageUrl(threadId, pageNum) { // // 如果当前是 /Forum/po/id/{threadId}/page/N.html 形式 // if (/^\/Forum\/po\/id\/\d+/.test(location.pathname)) { // return `${location.origin}/Forum/po/id/${threadId}/page/${pageNum}.html`; // } // // 默认 /t/{threadId}?page=N // return `${location.origin}/t/${threadId}?page=${pageNum}`; // } function computeNextUrl() { const tid = originInfo.threadId || document.querySelector('[data-threads-id]')?.getAttribute('data-threads-id'); if (!tid) return null; return buildThreadPageUrl(tid, lastLoadedPage + 1); } // ========== 新增:无缝翻页内部专用的刷新 / 判定工具(放在 loadNext 之前) ========== // done 为当前认定的末页添加手动局部刷新按钮,以避免回复数太少,无法滚动触发局部刷新,检测到为末页时一直存在 // 从 root 中获取第一个非预览的 .h-threads-list (避免误取预览区) // Phase1:getRealThreadsList / getMaxClonedPageInDOM 已上提到闭包外 // function getRealThreadsList(root = document) { // const lists = Array.from((root || document).querySelectorAll('.h-threads-list')); // return lists.find(el => !el.closest('.h-preview-box')) || null; // } // // 获取 DOM 中最大的 data-cloned-page(已被无缝加载进来的最大页) // function getMaxClonedPageInDOM() { // let max = 0; // document.querySelectorAll('.h-threads-item-replies[data-cloned-page]').forEach(el => { // const n = parseInt(el.getAttribute('data-cloned-page'), 10); // if (!isNaN(n) && n > max) max = n; // }); // return max; // } // 刷新目标回复区(主页面回复区 或 data-cloned-page = 最大的克隆页)并检查是否有下一页 // done(result) 回调会收到 { status: 'last'|'hasNext'|'error', nextPage?: number } // Phase2: old body replaced by shared-core thin wrapper // 刷新目标回复区(主页面回复区 或 data-cloned-page = 最大的克隆页)并检查是否有下一页 // done(result) 回调会收到 { status: 'last'|'hasNext'|'error', nextPage?: number } // Phase2:定区/增量/分页走共享核;末页判定与 toast 仍在本包装 function refreshRepliesAndCheckNext(done, options = {}, refreshGeneration) { const showResultToast = options.showResultToast !== false; const suppressResultToastOnHasNext = options.suppressResultToastOnHasNext !== false; const activeGeneration = refreshGeneration == null ? beginRefreshStatus() : refreshGeneration; if (!isCurrentRefreshStatus(activeGeneration)) return; try { const domMaxPage = getDomLastPageNum(); const maxCloned = getMaxClonedPageInDOM(); // 旧:内联 targetPage 分支(已收敛到 resolveThreadRefreshTargetPage) const resolved = resolveThreadRefreshTargetPage(domMaxPage, maxCloned, lastLoadedPage); // seamless 旧行为:未命中 cloned/main 时 targetPage 仍为 null 并继续刷主区 const targetPage = resolved.kind === 'cloned' ? resolved.targetPage : null; const list = getRealThreadsList(document); if (!list) { console.warn('[refreshReplies] 未找到 .h-threads-list'); showRefreshStatus('刷新回复失败,该串可能已被删除', 0, 'error'); return done && done({ status: "error" }); } const pageAttr = maxCloned > 0 ? maxCloned : 0; const ensured = ensureThreadRepliesContainer(list, pageAttr); const targetReplies = ensured.targetReplies; if (!targetReplies) { console.warn('[refreshReplies] 未找到 .h-threads-item,无法创建回复区'); toast('刷新回复失败,未找到串容器', 0, { type: 'error' }); return done && done({ status: "error" }); } if (ensured.created) { console.log('[refreshReplies] 已创建空的回复区容器'); } let fetchUrl = location.href; try { if (typeof originInfo !== 'undefined' && originInfo && originInfo.threadId && targetPage) { fetchUrl = buildThreadPageUrl(originInfo.threadId, targetPage); } else if (targetPage) { const u = new URL(location.href, location.origin); u.searchParams.set('page', String(targetPage)); fetchUrl = u.toString(); } } catch (e) {} fetch(fetchUrl, { credentials: 'same-origin' }) .then(res => res.text()) .then(html => { const doc = new DOMParser().parseFromString(html, 'text/html'); const newList = getRealThreadsList(doc); if (!newList) { console.warn('[refreshReplies] 抓取页面中未找到 .h-threads-list'); showRefreshStatus('刷新回复失败,该串可能已被删除', 0, 'error'); return done && done({ status: "error" }); } const newReplies = newList.querySelector('.h-threads-item-replies'); if (!newReplies) { console.warn('[refreshReplies] 抓取页面中未找到 .h-threads-item-replies'); toast('刷新回复失败,页面无回复内容', 0, { type: 'error' }); return done && done({ status: "error" }); } stripSystemTipReplies(newReplies); try { if (typeof hideEmptyTitleAndEmail === 'function') hideEmptyTitleAndEmail($(newReplies)); if (typeof applyFilters === 'function') applyFilters(cfg, newReplies); } catch (e) { console.warn('预处理过滤失败', e); } // Phase2:增量 append 走共享核(对齐旧:不过滤 9999999 在比较集合中的行为差异,由 strip 先清 new 侧) // ★ 计时点:只覆盖「响应到达后的同步 DOM 处理」(merge → applyPageEnhancements 同步段 → 回复统计)。 // 网络往返被有意排除——卡顿来自脚本同步工作,不是 fetch。 const __perfToken = startupPerfDebug.begin(); const merge = appendMissingRepliesByThreadsId(targetReplies, newReplies, { replyOnly: false, excludeSystemOnOld: false, excludeSystemOnNew: false }); const hasUpdate = merge.hasUpdate; const pagSync = prepareAndSyncBottomPagination(doc); const newPag = pagSync.newPag; try { if (typeof reinitForNewContent === 'function') reinitForNewContent(targetReplies); } catch (e) {} applyPageEnhancements(targetReplies, cfg, merge.appendedNodes); const allReplies = Array.from(targetReplies.querySelectorAll('.h-threads-item-reply')); const userReplies = allReplies.filter(el => el.getAttribute('data-threads-id') !== '9999999'); const userCount = userReplies.length; const parsedLastFromReturned = (function() { const pag = newPag || doc.querySelector('ul.uk-pagination.uk-pagination-left.h-pagination') || doc.querySelector('ul.uk-pagination'); return pag ? parseLastPageFromPagination(pag) : null; })(); if (__perfToken != null) startupPerfDebug.finish('refresh.checkNext.syncDom', __perfToken, () => ({ replies: userCount, updated: !!hasUpdate, domPages: document.querySelectorAll('.h-threads-item-replies[data-cloned-page]').length })); if (userCount < 19) { const result = { status: 'last', hasUpdate }; if (showResultToast && isCurrentRefreshStatus(activeGeneration)) showRefreshStatus(hasUpdate ? "已更新" : "无更新", hasUpdate ? 900 : 0, hasUpdate ? '' : 'hold'); if (typeof done === 'function') done(result); addRefreshButtonIfNeeded(); return; } if (parsedLastFromReturned && parsedLastFromReturned > lastLoadedPage) { const result = { status: 'hasNext', nextPage: lastLoadedPage + 1, hasUpdate }; if (showResultToast && !suppressResultToastOnHasNext) toast(hasUpdate ? "已更新" : "无更新", hasUpdate ? 900 : 0, { queue: false, key: 'refresh-status', type: hasUpdate ? '' : 'hold' }); if (typeof done === 'function') done(result); return; } else { const result = { status: 'last', hasUpdate }; if (showResultToast && isCurrentRefreshStatus(activeGeneration)) showRefreshStatus(hasUpdate ? "已更新" : "无更新", hasUpdate ? 900 : 0, hasUpdate ? '' : 'hold'); if (typeof done === 'function') done(result); addRefreshButtonIfNeeded(); return; } }) .catch(err => { console.error('refreshRepliesAndCheckNext error:', err); if (isCurrentRefreshStatus(activeGeneration)) showRefreshStatus('刷新回复区失败', 0, 'error'); if (typeof done === 'function') done({ status: 'error' }); }); } catch (err) { console.error('refreshRepliesAndCheckNext pre error:', err); if (typeof done === 'function') done({ status: 'error' }); } } // Phase1:extractFromHTML 已上提到闭包外共用实现 // function extractFromHTML(htmlText) { // const doc = new DOMParser().parseFromString(htmlText, 'text/html'); // const repliesAll = doc.querySelectorAll('.h-threads-item-replies'); // const replies = repliesAll.length ? repliesAll[0] : doc.querySelector('.h-threads-item-replies'); // let pagination = doc.querySelector('ul.uk-pagination.uk-pagination-left.h-pagination') || // doc.querySelector('ul.uk-pagination.uk-pagination-left') || // doc.querySelector('ul.uk-pagination'); // return { replies, pagination, doc }; // } // Phase3-3:刷新检查结果共用处理(刷新按钮 click / loadNext 末页旁路) // 语义:error 静默;hasNext → toast + prepareForceLoadNext + 延迟 loadNext;last → 已更新/无更新 function handleSeamlessRefreshCheckResult(result, refreshGeneration) { if (!result || result.status === 'error') return; if (!isCurrentRefreshStatus(refreshGeneration)) return; if (result.status === 'hasNext' && result.nextPage) { showRefreshStatus(`发现新回复,正在加载第 ${result.nextPage} 页……`, 700); // 旧:内联状态回退(已统一到 prepareForceLoadNext) // loadedPages.delete(result.nextPage); // loading = false; // lastLoadedPage = result.nextPage - 1; // lastCheckAt = 0; prepareForceLoadNext(result.nextPage); setTimeout(() => loadNext(refreshGeneration), 50); } else if (result.status === 'last') { // 「无更新」用 hold:无红框但长驻(无操作 30s / 交互后 5s 渐隐),避免很快被刷掉; // 「已更新」仍是普通短提示给足阅读时间 showRefreshStatus(result.hasUpdate ? '已更新' : '无更新', result.hasUpdate ? 1800 : 0, result.hasUpdate ? '' : 'hold'); // 结果提示给足阅读时间(原 900ms 偏短,易漏看后重复刷新) } } // Phase3-3:刷新按钮旁路(显示/overlay/分页监听与 loadNext 主路径分离) function getSeamlessBottomPagination() { const allPaginations = document.querySelectorAll('ul.uk-pagination'); return allPaginations.length ? allPaginations[allPaginations.length - 1] : null; } function updateSeamlessRefreshBtnDisplay(btn, pag) { if (!btn) return; if (!pag) { btn.style.display = 'none'; return; } const hasNext = !!pag.querySelector('li:last-child a'); if (hasNext) { btn.style.display = 'none'; return; } // 检查浮窗状态 const overlay = document.querySelector('.qp-overlay'); const overlayQuote = document.querySelector('.qp-overlay-quote'); const overlayOpen = (overlay && overlay.style.display === 'block'); const overlayQuoteOpen = (overlayQuote && overlayQuote.style.display === 'block'); if (overlayOpen || overlayQuoteOpen) { btn.style.display = 'none'; } else { // 显示时统一写 flex + 居中内联(覆盖任何 display:block 残留) btn.style.display = 'flex'; btn.style.alignItems = 'center'; btn.style.justifyContent = 'center'; } } function ensureSeamlessRefreshButtonNode() { let btn = document.getElementById('seamless-refresh-btn'); if (btn) return btn; btn = document.createElement('div'); btn.id = 'seamless-refresh-btn'; btn.className = 'qp-reset-btn seamless-refresh-btn xdex-icon-btn'; btn.title = '手动检查回复更新'; // btn.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path style="fill:none" d="M18 18A8.5 8.5 0 1 1 18.5 6.5"/><path style="fill:none" d="M18.5 6.5l-1.3 3.6"/><path style="fill:none" d="M19.2 10.6L18.5 6.5l-3.2 2.7"/></svg>'; btn.innerHTML = '<svg class="xdex-icon-sync" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 0 0-14.9-3"/><path d="M5.1 4v4h4"/><path d="M4 13a8 8 0 0 0 14.9 3"/><path d="M18.9 20v-4h-4"/></svg>'; // --- 固定位置样式(外观走 xdex-icon-btn,内联仅定位与显隐) --- btn.style.position = 'fixed'; btn.style.right = '12px'; btn.style.bottom = '60px'; // 层级置于拓展坞(.hld__docker z-index:9998)之上:串内页两者位置可能重叠;下行为 10000,勿再被覆盖 btn.style.zIndex = '10000'; btn.style.display = 'none'; // 默认不显示 document.body.appendChild(btn); // 点击触发"局部刷新 → 若有下一页则无缝翻页" btn.addEventListener('click', () => { try { btn.classList.add('xdex-icon-loading'); const refreshGeneration = beginRefreshStatus(); showRefreshStatus("正在刷新……", 1500); // 旧:内联 hasNext/last 分支(已抽到 handleSeamlessRefreshCheckResult) refreshRepliesAndCheckNext( (result) => { btn.classList.remove('xdex-icon-loading'); handleSeamlessRefreshCheckResult(result, refreshGeneration); }, { showResultToast: false }, refreshGeneration ); } catch (e) { btn.classList.remove('xdex-icon-loading'); console.warn('刷新按钮触发失败:', e); } }); return btn; } function observeSeamlessRefreshOverlays(btn) { const overlays = [document.querySelector('.qp-overlay'), document.querySelector('.qp-overlay-quote')]; overlays.forEach(el => { if (!el) return; // 同一元素只挂一次:浮窗为常驻单例,重复调用不应叠加属性观察器; // 浮窗若为新建元素则无此标记,仍会被观察,保留「晚出现的浮窗也能被挂上」的行为 if (el.__xdexSeamlessOverlayObserved) return; el.__xdexSeamlessOverlayObserved = true; // ★ 计数埋点(H1-H3 真机验收判据):实例数应恒定,不随开关浮窗次数增长 startupPerfDebug.record('observer.create:overlay', 0, { tag: 'qp-overlay' }); const obs = new MutationObserver(() => { updateSeamlessRefreshBtnDisplay(btn, getSeamlessBottomPagination()); }); obs.observe(el, { attributes: true, attributeFilter: ['style'] }); }); return btn; } // body 级与分页栏级观察器都提为共享单例:原实现每次调用都各自新建且从不 disconnect, // 数量随「刷新按钮点击 + 每次末页判定」线性累积,使每次 DOM 变更的固定开销成倍放大 let seamlessGlobalObserver = null; let seamlessPagObserver = null; let seamlessPagObserverTarget = null; // 兼容旧调用名;内部完成按钮创建 + overlay/分页监听 function addRefreshButtonIfNeeded() { // 旧:整段内联在 loadNext 旁(Phase3-3 拆为显示/监听辅助 + 共用结果处理) const btn = ensureSeamlessRefreshButtonNode(); // --- 始终监听页面最底部的分页栏 --- function observeBottomPagination() { const pag = getSeamlessBottomPagination(); if (!pag) return; // 先更新一次显示状态 updateSeamlessRefreshBtnDisplay(btn, pag); // 同一分页栏复用现有观察器,避免每次 DOM 变更都 disconnect + 重建 if (seamlessPagObserver && seamlessPagObserverTarget === pag) return; // 如果已有旧的 observer,先断开 if (seamlessPagObserver) { seamlessPagObserver.disconnect(); } // 新建 observer 监听底部分页栏的变化 // ★ 计数埋点(H2 真机验收判据):同目标复用时不重建,调用多次也应为 1 startupPerfDebug.record('observer.create:pag', 0, { tag: 'bottom-pagination' }); seamlessPagObserver = new MutationObserver(() => { updateSeamlessRefreshBtnDisplay(btn, getSeamlessBottomPagination()); }); seamlessPagObserver.observe(pag, { childList: true, subtree: true }); seamlessPagObserverTarget = pag; } // 初始绑定 observeSeamlessRefreshOverlays(btn); // 初始监听一次 observeBottomPagination(); // 每次 DOM 可能插入新分页栏时,重新绑定监听(只挂一次,回调内复用同一分页栏观察器逻辑) if (!seamlessGlobalObserver) { // ★ 计数埋点(H1 真机验收判据):body 级观察器必须恒为 1,不得随翻页/刷新次数增长 startupPerfDebug.record('observer.create:body', 0, { tag: 'document.body' }); seamlessGlobalObserver = new MutationObserver(() => { observeBottomPagination(); }); seamlessGlobalObserver.observe(document.body, { childList: true, subtree: true }); } } // 串内页加载 async function loadNext(refreshGeneration) { const activeGeneration = refreshGeneration == null ? beginRefreshStatus() : refreshGeneration; if (!isCurrentRefreshStatus(activeGeneration)) return; const loadNextStarted = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); startupPerfDebug.mark('seamless.loadNext.start', { lastLoadedPage, loading, done }); seamlessDebugLog('[loadNext] 函数被调用'); const now = Date.now(); seamlessDebugLog('[loadNext] 检查防抖: now - lastCheckAt =', now - lastCheckAt); if (now - lastCheckAt < 1000) { seamlessDebugLog('[loadNext] 防抖拦截,返回'); return; } lastCheckAt = now; seamlessDebugLog('[loadNext] 通过防抖检查'); const domLast = getDomLastPageNum(); // if (domLast && lastLoadedPage >= domLast) { // return; // } // 新增:基于当前页底分页DOM的“新鲜判定” function checkPaginationState(lastLoadedPage) { const allPaginations = document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); const bottomPag = allPaginations.length ? allPaginations[allPaginations.length - 1] : null; if (!bottomPag) { return { status: 'noPagination', stop: false }; } const lis = Array.from(bottomPag.querySelectorAll('li')); const numericLis = lis.filter(li => /^\d+$/.test(li.textContent.trim())); const hasNextLinkNow = lis.some(li => /下一页|下页|Next|›|»|→/i.test(li.textContent.trim())); // 只有一页 if (numericLis.length <= 1) { return { status: 'singlePage', stop: true, message: '仅一页,无需翻页' }; } // 已到末页 const maxDomLast = getDomLastPageNum(); if (maxDomLast && lastLoadedPage >= maxDomLast) { return { status: 'atLastPage', stop: true, message: '已经是最后一页了' }; } // 没有“下一页”按钮且也没有更大页码 if (!hasNextLinkNow && maxDomLast && lastLoadedPage + 1 > maxDomLast) { return { status: 'noNextLink', stop: true, message: '没有下一页' }; } // 正常情况 return { status: 'ok', stop: false }; } // 调用新鲜判定 const state = checkPaginationState(lastLoadedPage); if (state.stop) { if (state.message) { const now2 = Date.now(); if (now2 - lastFinalToastTs > 3000) { toast('正在检查新回复……', 900, { queue: false, key: 'refresh-status' }); lastFinalToastTs = now2; } } // 👉 每次末页判定时,都刷新最新回复区和分页 // 旧:内联 hasNext/last 分支(Phase3-3 已抽到 handleSeamlessRefreshCheckResult) const refreshGeneration = beginRefreshStatus(); refreshRepliesAndCheckNext( (result) => handleSeamlessRefreshCheckResult(result, refreshGeneration), { showResultToast: false }, refreshGeneration ); return; } // if (loading) return; // const nextPageNum = lastLoadedPage + 1; // if (loadedPages.has(nextPageNum)) return; seamlessDebugLog('[loadNext] loading =', loading); if (loading) { seamlessDebugLog('[loadNext] loading 为 true,返回'); return; } const nextPageNum = lastLoadedPage + 1; seamlessDebugLog('[loadNext] 计算下一页页码: lastLoadedPage =', lastLoadedPage, ', nextPageNum =', nextPageNum); seamlessDebugLog('[loadNext] loadedPages 包含的页码:', Array.from(loadedPages)); if (loadedPages.has(nextPageNum)) { seamlessDebugLog('[loadNext] nextPageNum 已在 loadedPages 中,返回'); return; } seamlessDebugLog('[loadNext] 准备加载页码:', nextPageNum); const nextUrl = computeNextUrl(); if (!nextUrl) { return; } showRefreshStatus(`正在加载第 ${nextPageNum} 页……`, 900); loading = true; try { const res = await startupPerfDebug.measureAsync('seamless.loadNext.fetch', () => fetch(nextUrl, { credentials: 'same-origin' }), { nextPageNum, nextUrl }); if (!res.ok) { if (isCurrentRefreshStatus(activeGeneration)) showRefreshStatus('刷新失败,网络错误', 0, 'error'); return; } const html = await startupPerfDebug.measureAsync('seamless.loadNext.responseText', () => res.text(), { nextPageNum }); const { replies, pagination } = startupPerfDebug.measure('seamless.loadNext.extractFromHTML', () => extractFromHTML(html), { nextPageNum, htmlLength: html.length }); if (!replies) { return; } let pagClone = startupPerfDebug.measure('seamless.loadNext.clonePagination', () => pagination ? pagination.cloneNode(true) : null, { nextPageNum }); if (!pagClone) { pagClone = document.createElement('ul'); pagClone.className = 'uk-pagination uk-pagination-left h-pagination'; } pagClone.setAttribute('hld-auto-page', 'ok'); removeIdsFromNode(pagClone); const lastPageNum = parseLastPageFromPagination(pagClone); if (lastPageNum) pagClone.setAttribute('data-last-page', String(lastPageNum)); pagClone.setAttribute('data-cloned-page', String(nextPageNum)); // 插入 DOM 前直接重建分页栏,避免先显示原版 3 页码再被 observer 二次修正造成闪烁/循环 if (typeof rebuildPaginationPages === 'function') rebuildPaginationPages(pagClone); if (typeof processPagination === 'function') processPagination(pagClone); const repliesClone = startupPerfDebug.measure('seamless.loadNext.cloneReplies', () => replies.cloneNode(true), { nextPageNum, replies: replies.querySelectorAll ? replies.querySelectorAll('.h-threads-item-reply').length : 0 }); repliesClone.setAttribute('data-cloned-page', String(nextPageNum)); removeIdsFromNode(repliesClone); // 插入 DOM 前同步执行会影响视觉的公共增强,避免用户看到未处理态闪烁 try { if (typeof preprocessPageEnhancementsBeforeInsert === 'function') preprocessPageEnhancementsBeforeInsert(repliesClone, cfg); } catch (e) {} const containers = getRootRepliesContainer(); if (!containers) { return; } const { lastReplies } = containers; startupPerfDebug.measure('seamless.loadNext.insertDom', () => { lastReplies.insertAdjacentElement('afterend', pagClone); pagClone.insertAdjacentElement('afterend', repliesClone); }, { nextPageNum }); // ======== 新增:让其他脚本对新内容生效 ======== startupPerfDebug.measure('seamless.loadNext.reinitForNewContent', () => reinitForNewContent(repliesClone), { nextPageNum }); startupPerfDebug.measure('seamless.loadNext.applyPageEnhancements', () => applyPageEnhancements(repliesClone, cfg), { nextPageNum }); // ============================================ // 更新底部分页条 const allPaginations = document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); if (allPaginations.length > 0) { const lastPagination = allPaginations[allPaginations.length - 1]; if (lastPagination && lastPagination !== pagClone) { lastPagination.replaceWith(pagClone.cloneNode(true)); } } loadedPages.add(nextPageNum); lastLoadedPage = nextPageNum; try { history.pushState(null, '', nextUrl); } catch (e) {} recordThreadHistoryProgress({ url: nextUrl, page: nextPageNum, reason: 'seamless-page', touchVisitedAt: true }); refreshEnhanceIslandAutoTitle(); ensureSentinelPlaced(); const hasNextLink = (() => { const anchorsText = Array.from(pagClone.querySelectorAll('a')).map(a => a.textContent.trim()); if (anchorsText.some(t => /下一页|下页|Next|next|›|»|→/.test(t))) return true; const parsed = parseLastPageFromPagination(pagClone); if (parsed && parsed > nextPageNum) return true; return false; })(); // 不在这里吐司“已经是最后一页了”,仅记录“末页已加载”的页码 if (!hasNextLink) { reachedLastPageAt = nextPageNum; } else { // 仍有下一页可能,清除标记 reachedLastPageAt = -1; } } catch (e) { console.warn('seamless paging loadNext error:', e); toast('加载失败,请稍后重试', 0, { type: 'error' }); return; } finally { loading = false; const finished = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); startupPerfDebug.record('seamless.loadNext.total', finished - loadNextStarted, { lastLoadedPage, loading, done }); startupPerfDebug.mark('seamless.loadNext.end', { lastLoadedPage, loading, done }); } } async function loadNextBoard() { const loadNextBoardStarted = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); startupPerfDebug.mark('seamless.loadNextBoard.start', { lastLoadedPage, loading, done }); if (loading || done) return; const nextPageNum = lastLoadedPage + 1; if (loadedPages.has(nextPageNum)) return; let nextUrl; if (PageType.isTimelinePage()) { // 时间线模式 const base = location.pathname.replace(/\/page\/\d+\.html$/, ''); // 去掉已有的 /page/N.html nextUrl = `${location.origin}${base}/page/${nextPageNum}.html`; } else { // 默认板块模式 nextUrl = `${location.origin}${location.pathname}?page=${nextPageNum}`; } loading = true; try { const res = await startupPerfDebug.measureAsync('seamless.loadNextBoard.fetch', () => fetch(nextUrl, { credentials: 'same-origin' }), { nextPageNum, nextUrl }); if (!res.ok) { done = true; return; } const html = await startupPerfDebug.measureAsync('seamless.loadNextBoard.responseText', () => res.text(), { nextPageNum }); const doc = startupPerfDebug.measure('seamless.loadNextBoard.parseHTML', () => new DOMParser().parseFromString(html, 'text/html'), { nextPageNum, htmlLength: html.length }); const list = doc.querySelector('.h-threads-list'); const pagination = doc.querySelector('ul.uk-pagination.uk-pagination-left.h-pagination'); // 板块页空页面检查(速报2) if (!list) { done = true; const now = Date.now(); if (now - lastFinalToastTs > 3000) { toast('已经是最后一页了'); lastFinalToastTs = now; } return; } // 检查 list 是否为空(没有子元素或只有空白内容) const hasContent = list.children.length > 0 || list.textContent.trim().length > 0; if (!hasContent) { done = true; const now = Date.now(); if (now - lastFinalToastTs > 3000) { toast('已经是最后一页了'); lastFinalToastTs = now; } return; } toast(`正在加载第 ${nextPageNum} 页……`); if (!list) { done = true; return; } const listClone = startupPerfDebug.measure('seamless.loadNextBoard.cloneList', () => list.cloneNode(true), { nextPageNum, threads: list.querySelectorAll ? list.querySelectorAll('.h-threads-item').length : 0 }); removeIdsFromNode(listClone); // 插入 DOM 前同步执行会影响视觉的公共增强,避免用户看到未处理态闪烁 try { if (typeof preprocessPageEnhancementsBeforeInsert === 'function') preprocessPageEnhancementsBeforeInsert(listClone, cfg); } catch (e) {} // 找到当前页面最后一个 .h-threads-list const lists = document.querySelectorAll('.h-threads-list'); const lastList = lists[lists.length - 1]; if (lastList) { let pagClone = pagination ? pagination.cloneNode(true) : null; if (pagClone) { removeIdsFromNode(pagClone); // 插入 DOM 前直接重建分页栏,避免原版 3 页码闪烁后再修正 if (typeof rebuildPaginationPages === 'function') rebuildPaginationPages(pagClone); if (typeof processPagination === 'function') processPagination(pagClone); startupPerfDebug.measure('seamless.loadNextBoard.insertDom', () => { lastList.insertAdjacentElement('afterend', pagClone); pagClone.insertAdjacentElement('afterend', listClone); }, { nextPageNum }); // ======== 新增:让其他脚本对新内容生效 ======== startupPerfDebug.measure('seamless.loadNextBoard.reinitForNewContent', () => reinitForNewContent(listClone), { nextPageNum }); startupPerfDebug.measure('seamless.loadNextBoard.applyPageEnhancements', () => applyPageEnhancements(listClone, cfg), { nextPageNum }); // ============================================ // 更新底部分页条 // ★ 测试版块页:守护放回的原生分页(页1高亮)插在列表1 之后、 // 本函数又把 pagClone(页2) 插到列表1 之后 → 出现「页1 + 页2」两条相邻分页栏。 // 修复:找出**除 titlePag 与 pagClone 外**的原生分页(守护放回的那条), // replaceWith 前先打 xdexSeamlessReplaced 豁免标记——守护器见标记即放行, // 否则 replaceWith 的移除会触发放回死循环。 const allPaginations = document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); for (const oldPag of allPaginations) { if (oldPag === pagClone) continue; if (oldPag.dataset && oldPag.dataset.xdexTitleClone === '1') continue; // 页首页码不动 oldPag.dataset.xdexSeamlessReplaced = '1'; oldPag.replaceWith(pagClone.cloneNode(true)); } } else { startupPerfDebug.measure('seamless.loadNextBoard.insertDom', () => lastList.insertAdjacentElement('afterend', listClone), { nextPageNum }); // ======== 新增:让其他脚本对新内容生效 ======== startupPerfDebug.measure('seamless.loadNextBoard.reinitForNewContent', () => reinitForNewContent(listClone), { nextPageNum }); try { if (cfg.enableQuotePreview && typeof enableQuotePreview === 'function') startupPerfDebug.measure('seamless.loadNextBoard.enableQuotePreview', () => enableQuotePreview(), { nextPageNum }); } catch (e) {} if (typeof initContent === 'function') { startupPerfDebug.measure('seamless.loadNextBoard.initContent', () => initContent(listClone), { nextPageNum }); // 重新绑定引用悬浮预览 } //autoHideRefView(listClone); // 拓展引用悬浮 // ============================================ } } loadedPages.add(nextPageNum); lastLoadedPage = nextPageNum; try { history.pushState(null, '', nextUrl); } catch (e) {} recordThreadHistoryProgress({ url: nextUrl, page: nextPageNum, reason: 'seamless-page', touchVisitedAt: true }); refreshEnhanceIslandAutoTitle(); const hasNextLink = pagination && Array.from(pagination.querySelectorAll('a')).some(a => /下一页|下页|Next|›|»|→/i.test(a.textContent)); //if (!hasNextLink) done = true; } catch (e) { console.warn('board paging loadNext error:', e); return; } finally { loading = false; const finished = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); startupPerfDebug.record('seamless.loadNextBoard.total', finished - loadNextBoardStarted, { lastLoadedPage, loading, done }); startupPerfDebug.mark('seamless.loadNextBoard.end', { lastLoadedPage, loading, done }); } } // 新增:检查激活大图并按需冻结/解冻观察器(限定在当前串的最后一个 replies 容器) function checkActiveImageAndToggleObserver() { // 旧:内联 nearBottom / activeInView 计算(已抽到 Phase3-2 共用) const activeBox = getSeamlessLastRepliesActiveImageBox(); const hasActive = !!activeBox; const maxDomLast = getDomLastPageNum(); const atLastPage = !!(maxDomLast && lastLoadedPage >= maxDomLast); const nearBottom = isSeamlessNearBottom(); const activeInView = isSeamlessElementInViewport(activeBox); // 末页 + 激活在视口内 + 未接近底部 → 冻结(disconnect) if (atLastPage && activeInView && !nearBottom) { try { observer && observer.disconnect(); } catch (e) {} observerFrozen = true; return; } // 其他情况 → 恢复观察(如果之前被冻结) if (observerFrozen && sentinel && observer) { try { observer.observe(sentinel); } catch (e) {} observerFrozen = false; } } // 新增:绑定点击检查(覆盖查看大图/收起等交互) document.addEventListener('click', (e) => { const t = e.target; if (t.closest('.h-threads-img-tool-btn') || t.closest('.h-threads-img-a')) { setTimeout(checkActiveImageAndToggleObserver, 0); } }); // Phase3-4:串内/板块自动观察器绑定(mode 策略表) // thread:保留末页缓冲 + 大图冻结;board:仅 done 门,不加串内专属能力 function bindSeamlessAutoLoad(mode) { const placeSentinel = (mode === 'board') ? ensureSentinelPlacedBoard : ensureSentinelPlaced; if (mode === 'board') { // 旧:initObserverBoard 内联(Phase3-2/3-4 收敛) // let observer = new IntersectionObserver(...); observer.observe(sentinel); observer = bindSeamlessIntersectionObserver(placeSentinel, (entry) => { // 原:entry.isIntersecting && !loading && !done + 向下滚动意图 if (shouldTriggerSeamlessAutoLoad(entry, !done)) { loadNextBoard(); } }); return; } // thread // 旧:内联 IntersectionObserver + 末页/大图冻结(已抽到 Phase3-2 共用绑定) // 使用外层 observer 变量(替换原来的 “let observer = ...”) observer = bindSeamlessIntersectionObserver(placeSentinel, (entry) => { // 新增:若冻结,直接忽略(被大图激活时冻结) if (observerFrozen) return; // 新增:末页 + 虚拟缓冲区 + 大图激活逻辑 const maxDomLast = getDomLastPageNum(); const atLastPage = !!(maxDomLast && lastLoadedPage >= maxDomLast); const nearBottom = isSeamlessNearBottom(); // 检测是否有激活大图且在视口中(限定在当前串的最后 replies) const activeInView = isSeamlessElementInViewport(getSeamlessLastRepliesActiveImageBox()); // 在末页时:如果激活大图且未接近底部 → 冻结观察器避免误触发 if (atLastPage && activeInView && !nearBottom) { try { observer.disconnect(); } catch (e) {} observerFrozen = true; return; } // 在末页时:开启“虚拟缓冲区”,只有 nearBottom 才允许触发(解决大图未激活也提前触发的问题) if (atLastPage && !nearBottom) { return; } // 原有的触发判定保留 if (shouldTriggerSeamlessAutoLoad(entry)) { loadNext(); } }); } // 兼容旧调用名 function initObserver() { bindSeamlessAutoLoad('thread'); } function initObserverBoard() { bindSeamlessAutoLoad('board'); } // Phase3-1:串内/板块手动按钮样式去重 function createSeamlessLoadMoreButton(onClick) { const btn = document.createElement('div'); btn.className = 'xdex-placeholder'; btn.textContent = '加载下一页'; btn.style.cssText = ` padding: 6px 10px; background: rgb(250, 250, 250); color: rgb(136, 136, 136); border: 1px dashed rgb(187, 187, 187); margin: 10px auto; cursor: pointer; width: 100%; box-sizing: border-box; text-align: center; `; btn.addEventListener('click', onClick); return btn; } function placeSeamlessLoadMoreButton(btn, placeSentinel) { placeSentinel(); if (sentinel && sentinel.parentNode) { sentinel.parentNode.insertBefore(btn, sentinel); } } // Phase3-4:串内/板块手动按钮绑定对称 function bindSeamlessManualLoad(mode) { // 旧:initManualButton / initManualButtonBoard 两套平行实现 const placeSentinel = (mode === 'board') ? ensureSentinelPlacedBoard : ensureSentinelPlaced; const onClick = (mode === 'board') ? (() => loadNextBoard()) : (() => { loadNext(); }); // 允许点击时触发 loadNext(Board),由加载函数负责末页提示(避免寂默返回) const btn = createSeamlessLoadMoreButton(onClick); placeSeamlessLoadMoreButton(btn, placeSentinel); } // 兼容旧调用名 function initManualButton() { // 旧:内联创建按钮样式(已抽到 createSeamlessLoadMoreButton) bindSeamlessManualLoad('thread'); } function initManualButtonBoard() { // 旧:与串内按钮同款内联样式(已抽到 createSeamlessLoadMoreButton) bindSeamlessManualLoad('board'); } // Phase3-4:启动分支按 mode 策略表,避免 thread/board 四套平行 if const seamlessMode = isThreadPage ? 'thread' : (isBoardPage ? 'board' : null); if (seamlessMode) { if (cfg.enableAutoSeamlessPaging) { ensureSeamlessSentinel(seamlessMode); bindSeamlessAutoLoad(seamlessMode); } else { bindSeamlessManualLoad(seamlessMode); } } // 调试:检查 loadNext 是否存在(默认静默;开 xdexSeamlessDebug 才输出) seamlessDebugLog('=== 定义 window.SeamlessPaging 前的检查 ==='); seamlessDebugLog('loadNext 类型:', typeof loadNext); seamlessDebugLog('loadNext 函数:', loadNext); const loadNextFunc = loadNext; // ← 先保存引用 seamlessDebugLog('loadNextFunc 类型:', typeof loadNextFunc); // 为拦截中间页发送成功分支提供无缝翻页调用 window.SeamlessPaging = { loadNext: function() { seamlessDebugLog('=== window.SeamlessPaging.loadNext 被调用 ==='); seamlessDebugLog('调用时 loadNext 类型:', typeof loadNext); seamlessDebugLog('调用时 loadNextFunc 类型:', typeof loadNextFunc); seamlessDebugLog('lastLoadedPage 当前值:', lastLoadedPage); seamlessDebugLog('loading 当前值:', loading); seamlessDebugLog('loadedPages 内容:', Array.from(loadedPages)); // 网络加载中或 50ms 重载定时器已排期:忽略重复触发,避免慢网下同一页被重复加载 if (loading || forceLoadPending) { seamlessDebugLog('重复触发翻页:loading 或 forceLoadPending 为 true,忽略'); return; } // 旧:内联状态回退(已统一到 prepareForceLoadNext;不传 nextPage 时清 lastLoadedPage+1) // loadedPages.delete(lastLoadedPage + 1); // 清除下一页的已加载标记 // loading = false; // 重置加载状态 // lastCheckAt = 0; // 重置防抖时间戳 const _forceNext = lastLoadedPage + 1; prepareForceLoadNext(); // 与原先 delete(lastLoadedPage+1) 语义一致 seamlessDebugLog('已删除页码:', _forceNext); seamlessDebugLog('loading 重置为:', loading); seamlessDebugLog('lastCheckAt 重置为:', lastCheckAt); seamlessDebugLog('准备在 50ms 后调用 loadNextFunc'); forceLoadPending = true; setTimeout(() => { forceLoadPending = false; seamlessDebugLog('=== setTimeout 内部执行 ==='); seamlessDebugLog('执行前 loadNextFunc 类型:', typeof loadNextFunc); try { loadNextFunc(); seamlessDebugLog('loadNextFunc 调用成功'); } catch (err) { console.error('loadNextFunc 调用失败:', err); } }, 50); } }; seamlessDebugLog('=== window.SeamlessPaging 定义完成 ==='); seamlessDebugLog('window.SeamlessPaging:', window.SeamlessPaging); addRefreshButtonIfNeeded(); } catch (err) { console.error('initSeamlessPaging failed', err); } } /* -------------------------------------------------- * tag 8. 移植‘X岛-揭示板的增强型体验’功能:启用高清图片链接+图片控件+布局调整/串在新标签页打开 * -------------------------------------------------- */ /* -------------------------------------------------- * 合并后的图片处理函数:高清图片 + 防溢出 + 图片控件 * -------------------------------------------------- */ // 屏蔽原站点的 initImageBox window.initImageBox = function() { console.debug("initImageBox 已被屏蔽,由 enableHDImageAndLayoutFix 接管"); }; // 新逻辑,启用高清图片链接和布局修正 const hdImageLazyLoader = (() => { // 预热距离固定为最大值:rootMargin 只能在重建 IntersectionObserver 时更改,过去按滚动速度动态调窗 // 会在滚动中高频 disconnect() + 全文档重新 observe(掉帧主因之一)。远近优先改由 measureImage 的 // 距离排序承担,不再靠改观察窗实现。 const ROOT_MARGIN_Y = 2400; // 速度采样最小窗(ms):nowTs() 为亚毫秒分辨率,同毫秒内 dt 若被钳成 1ms 会把瞬时速度放大上千倍, // 使匀速滚动也被判成急加速。固定采样窗兼作速度上限。 const SCROLL_SPEED_SAMPLE_MS = 100; // 【已停用·注释保留】动态预热窗常量(改固定窗以消除观察器重建风暴) // const MIN_ROOT_MARGIN_Y = 600; // const BASE_ROOT_MARGIN_Y = 600; // const MAX_ROOT_MARGIN_Y = 2400; // const LOOKAHEAD_SEC = 1.25; // const MARGIN_HYSTERESIS_PX = 100; // 非 GIF 原图并发上限;至少预留 1 槽给滚动方向前方,避免视口内大图占满导致下方不预热 const MAX_CONCURRENT = 3; const RESERVE_AHEAD_SLOTS = 1; const BEHIND_PENALTY_DISTANCE = 2500; let observer = null; let scrollListenerBound = false; let lastScrollY = window.scrollY || window.pageYOffset || 0; let lastScrollTs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); let scrollDirection = 1; let scrollSpeed = 0; // px/s,EMA let currentRootMarginY = ROOT_MARGIN_Y; // 固定窗;保留字段供 getStats 观察(恒定即证明无重建) let activeLoads = 0; let activeVisibleLoads = 0; let activeAheadLoads = 0; let activeBehindLoads = 0; let peakActiveLoads = 0; const queue = []; const queued = new Set(); const loadingKindMap = new WeakMap(); function getHdUrl(url) { const raw = String(url || ''); return raw.includes('/thumb/') ? raw.replace('/thumb/', '/image/') : raw; } function isGifUrl(url) { return /\.gif(?:$|[?#])/i.test(String(url || '')); } function nowTs() { return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); } // 【已停用·注释保留】动态窗计算(依赖已停用的速度查表,见 getDesiredRootMarginY) // function getDesiredRootMarginY() { // const fromSpeed = Math.round(scrollSpeed * LOOKAHEAD_SEC); // return Math.min(MAX_ROOT_MARGIN_Y, Math.max(MIN_ROOT_MARGIN_Y, BASE_ROOT_MARGIN_Y + fromSpeed)); // } function updateScrollMetrics(currentY) { const ts = nowTs(); const dy = currentY - lastScrollY; if (dy > 0) scrollDirection = 1; else if (dy < 0) scrollDirection = -1; // 方向每次都更新;速度只在跨过最小采样窗时更新,位移自上次提交点累计。 // 原实现每事件都算并把 dt 钳到 1ms,同毫秒内的多个事件会算出上千倍虚高速度。 const dt = ts - lastScrollTs; if (dt < SCROLL_SPEED_SAMPLE_MS) return; const instSpeed = Math.abs(dy) / dt * 1000; scrollSpeed = scrollSpeed * 0.7 + instSpeed * 0.3; lastScrollY = currentY; lastScrollTs = ts; } // 一次 rect 读取同时得出分类与优先级(原 classifyImage + getImagePriority 的合并,逻辑逐条照搬) function measureImage(img, vh) { const rect = img.getBoundingClientRect(); const aheadDistance = scrollDirection >= 0 ? rect.top - vh : -rect.bottom; const behindDistance = scrollDirection >= 0 ? -rect.bottom : rect.top - vh; const isVisible = rect.bottom >= 0 && rect.top <= vh; const kind = isVisible ? 'visible' : (aheadDistance >= 0 ? 'ahead' : (behindDistance > 0 ? 'behind' : 'visible')); const imgCenter = rect.top + rect.height / 2; const viewportCenter = vh / 2; let priority; if (isVisible) priority = Math.abs(imgCenter - viewportCenter); else if (aheadDistance >= 0) priority = 10000 + aheadDistance; else if (behindDistance > 0) priority = (behindDistance > BEHIND_PENALTY_DISTANCE ? 1000000 : 100000) + behindDistance; else priority = 200000 + Math.abs(imgCenter - viewportCenter); return { kind, priority }; } // 单次遍历产出分类、优先级与 visible/ahead 计数,供本轮 processQueue 全程复用; // 取代原“排序逐项回查 rect + 每个候选扫全队列”的 O(N²) 布局读取 function measureQueue() { const __perfToken = startupPerfDebug.begin(); const meta = new Map(); const vh = window.innerHeight || document.documentElement.clientHeight || 0; let aheadCount = 0; let visibleCount = 0; for (let i = 0; i < queue.length; i++) { const img = queue[i]; if (!img || !img.isConnected) continue; const m = measureImage(img, vh); meta.set(img, m); if (m.kind === 'ahead') aheadCount++; else if (m.kind === 'visible') visibleCount++; } // ★ 队列长度即 rect 读取次数,是判断成本是否随图片数放大的唯一依据 startupPerfDebug.finish('hdLazy.measureQueue', __perfToken, () => ({ rects: queue.length, ahead: aheadCount, visible: visibleCount })); return { meta, aheadCount, visibleCount }; } function getSnapPriority(snap, img) { const m = snap.meta.get(img); return m ? m.priority : 0; } function getSnapKind(snap, img) { const m = snap.meta.get(img); return m ? m.kind : 'visible'; } function canStartNonGif(kind, snap) { const free = MAX_CONCURRENT - activeLoads; if (free <= 0) return false; if (kind === 'ahead') return true; if (kind === 'visible') { // 前方仍有待加载时,不为视口内请求吃光最后 RESERVE 个槽 if (!snap.aheadCount) return true; return (free - 1) >= RESERVE_AHEAD_SLOTS || activeAheadLoads > 0; } // behind:仅当没有 visible/ahead 候选时才填空 return !snap.visibleCount && !snap.aheadCount; } function noteLoadStart(kind) { activeLoads++; peakActiveLoads = Math.max(peakActiveLoads, activeLoads); if (kind === 'visible') activeVisibleLoads++; else if (kind === 'ahead') activeAheadLoads++; else activeBehindLoads++; } function noteLoadEnd(kind) { activeLoads = Math.max(0, activeLoads - 1); if (kind === 'visible') activeVisibleLoads = Math.max(0, activeVisibleLoads - 1); else if (kind === 'ahead') activeAheadLoads = Math.max(0, activeAheadLoads - 1); else activeBehindLoads = Math.max(0, activeBehindLoads - 1); } // 【已停用·注释保留】观察器重建链:desired 窗改固定 ROOT_MARGIN_Y 后不再需要。 // 原实现在滚动中高频 disconnect() + 全文档 querySelectorAll + 逐个 observe + 新建 IO。 // function reobservePendingImages() { // const io = ensureObserver(); // if (!io) return; // document.querySelectorAll('img[data-xdex-hd-src]').forEach((img) => { // if (img.dataset.xdexHdLoaded === '1' || img.dataset.xdexHdLoading === '1') return; // io.observe(img); // }); // } // function syncObserverMargin(force) { // const desired = getDesiredRootMarginY(); // if (!force && Math.abs(desired - currentRootMarginY) < MARGIN_HYSTERESIS_PX) return; // if (!force && desired === currentRootMarginY) return; // currentRootMarginY = desired; // if (observer) { // try { observer.disconnect(); } catch (e) {} // observer = null; // } // reobservePendingImages(); // } let scrollFrameId = 0; function onScrollFrame() { const __perfToken = startupPerfDebug.begin(); const currentY = window.scrollY || window.pageYOffset || 0; updateScrollMetrics(currentY); processQueue(); startupPerfDebug.finish('hdLazy.onScrollFrame', __perfToken, () => ({ queue: queue.length, activeLoads })); } function bindScrollListener() { if (scrollListenerBound) return; window.addEventListener('scroll', () => { // 一帧只处理一次:滚动事件每秒可达数十个,逐事件处理的结果会被后一次覆盖,纯浪费 if (scrollFrameId) return; if (typeof requestAnimationFrame !== 'function') { onScrollFrame(); return; } scrollFrameId = requestAnimationFrame(() => { scrollFrameId = 0; onScrollFrame(); }); }, { passive: true }); scrollListenerBound = true; } function ensureObserver() { if (observer) return observer; if (typeof IntersectionObserver !== 'function') return null; observer = new IntersectionObserver(entries => { // 整批入队后只处理一次:逐张 enqueue 会让每张图都触发一整套测量+排序 let added = false; entries.forEach(entry => { if (!entry.isIntersecting) return; const img = entry.target; if (enqueue(img, true)) added = true; try { observer.unobserve(img); } catch (e) {} }); if (added) processQueue(); }, { root: null, rootMargin: `${ROOT_MARGIN_Y}px 0px`, threshold: 0.01 }); return observer; } function prepare(img, hdUrl) { if (!img) return ''; const finalHdUrl = getHdUrl(hdUrl || img.currentSrc || img.src || img.getAttribute('src') || ''); if (!finalHdUrl || !finalHdUrl.includes('/image/')) return ''; if (!img.dataset.xdexThumbSrc) { img.dataset.xdexThumbSrc = img.currentSrc || img.src || img.getAttribute('src') || ''; } img.dataset.xdexHdSrc = finalHdUrl; img.loading = 'lazy'; return finalHdUrl; } function collect(root) { if (!root) return []; const imgs = []; if (root.matches && root.matches('img[data-xdex-hd-src]')) imgs.push(root); if (root.querySelectorAll) { root.querySelectorAll('img[data-xdex-hd-src]').forEach(img => imgs.push(img)); } return imgs; } // 【已停用·注释保留】优先级计算已并入 measureImage(同一次 rect 读取同时给出分类与优先级) // function getImagePriority(img) { // const rect = img.getBoundingClientRect(); // const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; // const viewportCenter = viewportHeight / 2; // const imgCenter = rect.top + rect.height / 2; // const aheadDistance = scrollDirection >= 0 ? rect.top - viewportHeight : -rect.bottom; // const behindDistance = scrollDirection >= 0 ? -rect.bottom : rect.top - viewportHeight; // const isVisible = rect.bottom >= 0 && rect.top <= viewportHeight; // const isAhead = aheadDistance >= 0; // const isBehind = behindDistance > 0; // if (isVisible) return Math.abs(imgCenter - viewportCenter); // if (isAhead) return 10000 + aheadDistance; // if (isBehind) return (behindDistance > BEHIND_PENALTY_DISTANCE ? 1000000 : 100000) + behindDistance; // return 200000 + Math.abs(imgCenter - viewportCenter); // } // 图片完成回调会在同一批任务里多次触发,每次都跑一轮 measureQueue(N 次 rect 读取)。 // 合并到下一个宏任务只跑一轮;用 setTimeout 而非 rAF:后台标签页 rAF 不触发会卡住整个队列 let queueScheduleId = 0; function scheduleProcessQueue() { if (queueScheduleId) return; queueScheduleId = setTimeout(() => { queueScheduleId = 0; processQueue(); }, 0); } function processQueue() { if (queue.length === 0) return; // GIF:不占非 GIF 并发槽,直接开载 for (let i = queue.length - 1; i >= 0; i--) { const img = queue[i]; const hdSrc = img && img.dataset ? img.dataset.xdexHdSrc : ''; if (hdSrc && isGifUrl(hdSrc)) { queue.splice(i, 1); queued.delete(img); if (img && img.isConnected && img.dataset.xdexHdLoaded !== '1' && img.dataset.xdexHdLoading !== '1') { load(img, hdSrc); } } } if (queue.length === 0) return; // 单次测量:分类、优先级与 ahead/visible 计数一次算完,本轮全程复用 const snap = measureQueue(); queue.sort((a, b) => getSnapPriority(snap, a) - getSnapPriority(snap, b)); // 多轮挑选:跳过暂时因 reserve 不能启动的项,继续尝试后方合适项 let guard = queue.length + 2; while (activeLoads < MAX_CONCURRENT && queue.length > 0 && guard-- > 0) { let started = false; for (let i = 0; i < queue.length; i++) { const img = queue[i]; if (!img || !img.isConnected) { queue.splice(i, 1); queued.delete(img); started = true; break; } if (img.dataset.xdexHdLoaded === '1' || img.dataset.xdexHdLoading === '1') { queue.splice(i, 1); queued.delete(img); started = true; break; } const hdSrc = img.dataset.xdexHdSrc; if (!hdSrc) { queue.splice(i, 1); queued.delete(img); started = true; break; } if (isGifUrl(hdSrc)) { queue.splice(i, 1); queued.delete(img); load(img, hdSrc); started = true; break; } const kind = getSnapKind(snap, img); if (!canStartNonGif(kind, snap)) continue; queue.splice(i, 1); queued.delete(img); noteLoadStart(kind); loadingKindMap.set(img, kind); let finished = false; const finish = () => { if (finished) return; finished = true; const k = loadingKindMap.get(img) || kind; loadingKindMap.delete(img); noteLoadEnd(k); scheduleProcessQueue(); }; img.addEventListener('load', finish, { once: true }); img.addEventListener('error', finish, { once: true }); load(img, hdSrc); if (img.dataset.xdexHdLoaded === '1' || img.dataset.xdexHdLoading !== '1') finish(); started = true; break; } if (!started) break; } } function enqueue(img, deferProcess) { if (!img || img.dataset.xdexHdLoaded === '1' || img.dataset.xdexHdLoading === '1') return false; if (!img.dataset.xdexHdSrc) return false; // GIF 直接加载,不进限速队列 if (isGifUrl(img.dataset.xdexHdSrc)) { load(img, img.dataset.xdexHdSrc); return false; } const added = !queued.has(img); if (added) { queued.add(img); queue.push(img); } // deferProcess=true 时由调用方在整批入队后统一处理一次 if (!deferProcess) processQueue(); return added; } function observe(root) { bindScrollListener(); const io = ensureObserver(); if (!io) return; collect(root).forEach(img => { if (img.dataset.xdexHdLoaded === '1') return; // GIF:观察前即可直接开载 if (img.dataset.xdexHdSrc && isGifUrl(img.dataset.xdexHdSrc)) { load(img, img.dataset.xdexHdSrc); return; } io.observe(img); }); } function load(img, hdUrl) { if (!img) return; const finalHdUrl = prepare(img, hdUrl); if (!finalHdUrl) return; if ((img.currentSrc || img.src) === finalHdUrl) { img.dataset.xdexHdLoaded = '1'; delete img.dataset.xdexHdLoading; return; } if (img.dataset.xdexHdLoading === '1') return; img.dataset.xdexHdLoading = '1'; const thumbSrc = img.dataset.xdexThumbSrc || ''; const onLoad = () => { img.dataset.xdexHdLoaded = '1'; delete img.dataset.xdexHdLoading; }; const onError = () => { delete img.dataset.xdexHdLoading; if (thumbSrc && (img.currentSrc || img.src) === finalHdUrl) img.src = thumbSrc; }; img.addEventListener('load', onLoad, { once: true }); img.addEventListener('error', onError, { once: true }); img.src = finalHdUrl; } function getStats() { return { activeLoads, activeVisibleLoads, activeAheadLoads, activeBehindLoads, peakActiveLoads, queued: queue.length, maxConcurrent: MAX_CONCURRENT, reserveAheadSlots: RESERVE_AHEAD_SLOTS, scrollDirection, scrollSpeed: Math.round(scrollSpeed), rootMarginY: currentRootMarginY }; } return { getHdUrl, isGifUrl, prepare, observe, load, getStats }; })(); const hdImageDebugTarget = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; function clearHDImageTiming() { if (performance && typeof performance.clearResourceTimings === 'function') { performance.clearResourceTimings(); } return 'cleared'; } function reportHDImageConcurrency(options = {}) { const includeGif = !!options.includeGif; const showTable = options.table !== false; const entries = performance.getEntriesByType('resource') .filter(e => e && e.initiatorType === 'img') .filter(e => String(e.name || '').includes('/image/')) .filter(e => includeGif || !/\.gif(?:$|[?#])/i.test(String(e.name || ''))) .filter(e => Number.isFinite(e.startTime) && Number.isFinite(e.responseEnd) && e.responseEnd > e.startTime) .sort((a, b) => a.startTime - b.startTime); let peak = 0; let peakAt = 0; entries.forEach(a => { let active = 0; entries.forEach(b => { if (b.startTime <= a.startTime && b.responseEnd >= a.startTime) active++; }); if (active > peak) { peak = active; peakAt = a.startTime; } }); const rows = entries.map(e => ({ start: Math.round(e.startTime), end: Math.round(e.responseEnd), duration: Math.round(e.duration), url: String(e.name || '').slice(-100) })); if (showTable && rows.length) console.table(rows); const result = { peakResourceOverlap: peak, peakAt: Math.round(peakAt), countedRequests: entries.length, includeGif, loader: hdImageLazyLoader.getStats() }; console.log('[XDEX HD image concurrency]', result); return result; } window.__xdexClearHDImageTiming = clearHDImageTiming; window.__xdexReportHDImageConcurrency = reportHDImageConcurrency; hdImageDebugTarget.__xdexClearHDImageTiming = clearHDImageTiming; hdImageDebugTarget.__xdexReportHDImageConcurrency = reportHDImageConcurrency; function isSettingsPanelImageEnhancementRoot(root) { if (!root || root === document) return false; const node = root.nodeType === 1 ? root : root.parentElement; return !!(node && node.closest && node.closest('#sp_panel')); } function enableHDImageAndLayoutFix(root = document) { return startupPerfDebug.measure('enableHDImageAndLayoutFix', () => { if (isSettingsPanelImageEnhancementRoot(root)) return; const isDocumentRoot = root === document; if (isDocumentRoot && enableHDImageAndLayoutFix.__documentProcessed) { handlePendingHDImageAndLayoutFix(document); return; } if (isDocumentRoot) enableHDImageAndLayoutFix.__documentProcessed = true; // ==================== 注入样式(只注入一次)==================== if (!document.getElementById('prevent-overflow-style')) { const style = document.createElement('style'); style.id = 'prevent-overflow-style'; style.textContent = ` .h-threads-content, .h-threads-item-reply-main, .h-preview-box { max-width: 100%; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; box-sizing: border-box; } .h-threads-img-box.h-active { max-width: 100%; box-sizing: border-box; overflow: visible; } .h-threads-img-box.h-active .h-threads-img-a { display: block; position: relative; overflow: hidden; box-sizing: border-box; transition: width 0.3s ease, height 0.3s ease; } .h-threads-img-box.h-active .h-threads-img { display: block; box-sizing: border-box; position: relative; transform-origin: center center; transition: transform 0.2s ease-out, margin 0.2s ease-out; } .h-threads-content img:not(.h-threads-img), .h-preview-box img { max-width: 100% !important; height: auto !important; box-sizing: border-box; } .h-threads-content pre, .h-preview-box pre { max-width: 100%; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; box-sizing: border-box; } .h-threads-content table, .h-preview-box table { max-width: 100%; overflow-x: auto; display: block; box-sizing: border-box; } .h-threads-content a, .h-preview-box a { word-break: break-all; overflow-wrap: break-word; } `; document.head.appendChild(style); } // ==================== 子函数1: 布局计算和溢出处理 ==================== const handleImageLayout = { getElementContentWidth(el) { if (!el) return 0; const style = getComputedStyle(el); const paddingX = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0); const width = el.clientWidth || el.offsetWidth || 0; return Math.max(0, Math.floor(width - paddingX)); }, // ★ 新增:根据场景(板块页/串内页)计算消息容器最大允许宽度 getMaxMsgWidth(msgMain) { // ===== 新增:引用浮窗场景 ===== const quoteBox = msgMain.closest('.qp-quote'); if (quoteBox) { return this.getElementContentWidth(quoteBox); } // 基础浏览器边缘限制 const viewportLimit = window.innerWidth - 240; // 保留你的全局边距逻辑 // 串内页默认上限 const threadPageCap = 1200; // 判断是否处于板块页的回复结构(存在 .h-threads-item-index) const threadItem = msgMain.closest('.h-threads-item'); const isBoardPage = threadItem && threadItem.classList.contains('h-threads-item-index'); if (!isBoardPage) { // 串内页:使用原有逻辑(viewport 边缘 + 1200 上限) return Math.min(viewportLimit, threadPageCap); } // 板块页:右侧区域限制 // 右侧区域宽度 = 整个串容器宽度 - 左侧回复序号图标宽度 - 留边距40 const iconEl = msgMain.previousElementSibling && msgMain.previousElementSibling.classList.contains('h-threads-item-reply-icon') ? msgMain.previousElementSibling : null; const iconWidth = iconEl ? iconEl.offsetWidth || 0 : 0; const threadWidth = threadItem.offsetWidth || viewportLimit; const rightRegionWidth = Math.max(0, threadWidth - iconWidth - 40); // 不能超过浏览器边缘限制 return Math.min(rightRegionWidth, viewportLimit); }, // ★ 新增:未激活时也拓展消息容器宽度 expandMsgWidthIfImageExists(msgMain) { const imgBox = msgMain.querySelector('.h-threads-img-box'); if (!imgBox) return; // 没有图片则跳过 // 预览框为空壳预览,不参与未激活加宽;否则早期注入时冻结的 px 与正式预览框重算值不一致,替换时会看到宽度跳变 if (msgMain.closest('.h-preview-box')) return; // ☆ 新增:检查是否已经扩展过,如果已扩展则跳过 if (msgMain.__imageWidthExpanded === true) return; // 如果图片未激活 if (!imgBox.classList.contains('h-active')) { const currentWidth = msgMain.offsetWidth; // ★ 未参与布局(隐藏/游离)时量不到宽度,此刻不做判定也不置锁 if (!currentWidth) { // ★ 事件驱动补判定:pending 队列对每个节点只处理一次,若插入时容器尚不可测量(所在容器 // 隐藏/未布局完成),该回复要等下一次全文档 pass(window.load 或触底增量更新)才有机会 // 补写,期间一直保持窄列。这里用 ResizeObserver 精确等「容器从 0 宽变为有实际尺寸」的 // 那一次回调:量到宽度即按正常流程写入并置锁、随后断开观察;仍为 0 宽(继续隐藏)则不 // 动作也不轮询。永久隐藏的回复只会有一次 0 尺寸初始回调,无后续开销。 if (typeof ResizeObserver === 'function' && !msgMain.__xdexExpandWidthRO) { // ★ 计数埋点:本分支为「容器未布局」的兜底,每个未布局容器挂一个; // 数量即「未布局带图回复数」,多图长串应关注其总数 startupPerfDebug.record('observer.create:expandWidthRO', 0, { tag: 'msg-main' }); const ro = new ResizeObserver(() => { if (!msgMain.offsetWidth) return; // 仍不可测量(含 observe 时的 0 尺寸初始回调) ro.disconnect(); msgMain.__xdexExpandWidthRO = null; if (msgMain.isConnected && msgMain.__imageWidthExpanded !== true) { handleImageLayout.expandMsgWidthIfImageExists(msgMain); } }); ro.observe(msgMain); msgMain.__xdexExpandWidthRO = ro; } return; } const maxMsgWidth = this.getMaxMsgWidth(msgMain); // ★ 加宽量 = 固定预算(getExpandedWidthBudget):按「本步骤之后仍会改变容器宽度的操作」的 // 最坏叠加一次性算入并写入,不做实时测量,之后不再重算,避免渲染后的反复横向变化 const targetWidth = Math.min(currentWidth + this.getExpandedWidthBudget(), maxMsgWidth); // ★ 目标宽度非法则不写入也不置锁(写入无效值时浏览器会丢弃该声明,锁却已置位 → 该回复永久不加宽) if (!Number.isFinite(targetWidth) || targetWidth <= 0) return; // 保存原始宽度,便于之后恢复 if (msgMain.__originalWidth === undefined) { msgMain.__originalWidth = msgMain.style.width || ''; } msgMain.style.width = targetWidth + 'px'; // ☆ 新增:标记已经扩展过,防止重复调用时继续加宽(仅在成功写入宽度后置位) msgMain.__imageWidthExpanded = true; } }, // ★ 加宽预算(固定常数,不做实时测量):按「本步骤之后仍会改变 main 容器宽度的操作」的最坏叠加取值, // 一次写入后不再重算,避免渲染后的反复横向变化。构成: // - 图片加载形态切换 ≈ 200px:原图(image/)与缩略图(thumb/)两种形式的非激活预览宽度均为固定值 // (原图 ≈ 270px,缩略图按长边缩放、竖长图最窄 ≈ 70px,如 PDF 长页 90 → 270),最坏差 ≈ 200px // - 相对时间形态演化 ≈ 101px:最短形态「今天(四)N秒前」(≈5.7em) → 最坏「N年前 YYYY-MM-DD(四)HH:MM:SS」 // (≈13.4em),差 ≈ 7.75em,按信息行 ~13px 字号折算(跨年只变数字,宽度不再增长) // - 饼干大小写 ≈ 18px:饼干为 7 位,全大写 ID 在非等宽字体下比小写宽(按 7 字符 ID 估) // - 饼干标记内边距 6px:markAllCookies 给 ID 文本加 0 3px,可能晚于本步骤生效 // 不产生增量的项(预算 0):编号/Po 标记(只改 3em 定宽的图标单元格)、去除无标题/无名氏(只收窄, // 且已在本步骤前生效)。观感调整只需改这一个返回值。 getExpandedWidthBudget() { return (270 - 70) + 101 + 18 + 6; // ≈ 325px(270/70 = 原图/缩略图形式的预览宽常数) }, // 预计算图片在所有旋转角度下的尺寸 precalculateImageSizes(naturalWidth, naturalHeight, maxWidth) { const sizes = {}; // 0° 和 180°(宽高不变) let horizontal = { containerWidth: naturalWidth, containerHeight: naturalHeight, imgWidth: naturalWidth, imgHeight: naturalHeight, scale: 1 }; // 判断当前朝向下,应该按哪个维度适配 if (naturalWidth > maxWidth) { // 宽度超限,按宽度等比缩放 const scale = maxWidth / naturalWidth; horizontal.containerWidth = maxWidth; horizontal.containerHeight = Math.floor(naturalHeight * scale); horizontal.imgWidth = maxWidth; horizontal.imgHeight = Math.floor(naturalHeight * scale); horizontal.scale = scale; // } else if (naturalHeight > maxWidth && naturalWidth < maxWidth * 0.8) { // // 宽度明显小于最大宽度(小于80%)且高度超限时,才按高度等比缩放 // const scale = maxWidth / naturalHeight; // horizontal.containerWidth = Math.floor(naturalWidth * scale); // horizontal.containerHeight = maxWidth; // horizontal.imgWidth = Math.floor(naturalWidth * scale); // horizontal.imgHeight = maxWidth; // horizontal.scale = scale; // } } else { // 宽度未超限,保持原尺寸或适当放大 // 不做任何处理,使用初始值(原尺寸) } // 如果宽高都不超限,保持原尺寸(已在初始化时设置) sizes[0] = sizes[180] = horizontal; // 90° 和 270°(宽高互换) let vertical = { containerWidth: naturalHeight, // 容器宽度 = 原图高度 containerHeight: naturalWidth, // 容器高度 = 原图宽度 imgWidth: naturalWidth, imgHeight: naturalHeight, scale: 1 }; // 旋转后,容器的宽度实际是原图的高度 if (naturalHeight > maxWidth) { // 旋转后宽度(原图高度)超限,按原图高度等比缩放 const scale = maxWidth / naturalHeight; vertical.containerWidth = maxWidth; vertical.containerHeight = Math.floor(naturalWidth * scale); vertical.imgWidth = Math.floor(naturalWidth * scale); vertical.imgHeight = maxWidth; vertical.scale = scale; } else if (naturalWidth > maxWidth) { // 旋转后宽度未超限但高度(原图宽度)超限,按原图宽度等比缩放 const scale = maxWidth / naturalWidth; vertical.containerWidth = Math.floor(naturalHeight * scale); vertical.containerHeight = maxWidth; vertical.imgWidth = maxWidth; vertical.imgHeight = Math.floor(naturalHeight * scale); vertical.scale = scale; } // 如果宽高都不超限,保持原尺寸(已在初始化时设置) sizes[90] = sizes[270] = vertical; return sizes; }, // 应用预计算的尺寸 applyImageSize(imgBox, rotateIndex) { const imgA = imgBox.querySelector('.h-threads-img-a'); const img = imgBox.querySelector('.h-threads-img'); if (!imgA || !img || !imgBox.__sizeCache) return; img.removeAttribute('align'); img.removeAttribute('hspace'); imgBox.style.float = 'none'; imgA.style.float = 'none'; img.style.float = 'none'; // 强制重置图片定位为 relative,确保在容器内正确定位 img.style.position = 'relative'; img.style.top = 'auto'; img.style.left = 'auto'; // 根据 rotateIndex 确定旋转角度 // const rotation = (rotateIndex * 90) % 360; // 不取模,保持角度单调累加 const rotation = rotateIndex * 90; // 用于查找尺寸缓存的归一化角度 const normalized = ((rotation % 360) + 360) % 360; let targetRotation; if (normalized === 0) targetRotation = 0; else if (normalized === 90) targetRotation = 90; else if (normalized === 180) targetRotation = 180; else if (normalized === 270) targetRotation = 270; else targetRotation = 0; // 归一化到 0, 90, 180, 270 // let targetRotation; // if (rotation === 0) { // targetRotation = 0; // } else if (rotation === 90) { // targetRotation = 90; // } else if (rotation === 180) { // targetRotation = 180; // } else if (rotation === 270) { // targetRotation = 270; // } else { // targetRotation = 0; // } const size = imgBox.__sizeCache[targetRotation]; if (!size) return; const isRotated90or270 = (targetRotation === 90 || targetRotation === 270); // 设置容器尺寸(容器不旋转,只改变宽高) imgA.style.width = size.containerWidth + 'px'; imgA.style.height = size.containerHeight + 'px'; imgA.style.maxWidth = imgBox.__maxWidth + 'px'; // 设置图片尺寸 img.style.width = size.imgWidth + 'px'; img.style.height = size.imgHeight + 'px'; img.style.maxWidth = 'none'; img.style.maxHeight = 'none'; // 图片只需要旋转,不需要translate偏移(因为容器已经是正确尺寸) img.style.transform = `rotate(${rotation}deg) scale(1.02)`; setTimeout(() => { img.style.transform = `rotate(${rotation}deg) scale(1)`; }, 50); if (isRotated90or270) { const marginTop = (size.containerHeight - size.imgHeight) / 2; const marginLeft = (size.containerWidth - size.imgWidth) / 2; img.style.marginTop = marginTop + 'px'; img.style.marginLeft = marginLeft + 'px'; } else { img.style.display = ''; img.style.marginTop = '0px'; img.style.marginLeft = '0px'; img.style.marginRight = '0px'; img.style.marginBottom = '0px'; } // 使用margin居中图片 // if (isRotated90or270) { // const marginTop = (size.containerHeight - size.imgHeight) / 2; // let marginLeft = (size.containerWidth - size.imgWidth) / 2; // // ★ 修正:避免负 margin 导致图片超出容器 // if (marginLeft < 0) marginLeft = 0; // img.style.marginTop = marginTop + 'px'; // img.style.marginLeft = marginLeft + 'px'; // } else { // img.style.marginTop = '0px'; // img.style.marginLeft = '0px'; // } // 设置容器和图片尺寸保持一致 // if (isRotated90or270) { // // 旋转 90°/270° 时,容器宽高直接跟随图片 // imgA.style.width = size.imgWidth + 'px'; // imgA.style.height = size.imgHeight + 'px'; // } else { // // 0°/180° 时,容器用正常计算值 // imgA.style.width = size.containerWidth + 'px'; // imgA.style.height = size.containerHeight + 'px'; // } // imgA.style.maxWidth = imgBox.__maxWidth + 'px'; // // 设置图片尺寸 // img.style.width = size.imgWidth + 'px'; // img.style.height = size.imgHeight + 'px'; // img.style.maxWidth = 'none'; // img.style.maxHeight = 'none'; }, // 处理激活状态的图片盒子 handleActiveImageBox(imgBox, forceRecalculate = false) { const imgA = imgBox.querySelector('.h-threads-img-a'); const img = imgBox.querySelector('.h-threads-img'); if (!imgA || !img) return; const isActive = imgBox.classList.contains('h-active'); if (!isActive) { if (imgBox.__activeLayoutFrame) { cancelAnimationFrame(imgBox.__activeLayoutFrame); imgBox.__activeLayoutFrame = 0; } // 取消激活:恢复原始状态并清除缓存 if (imgBox.__originalStyles) { imgA.style.width = imgBox.__originalStyles.aWidth || ''; imgA.style.height = imgBox.__originalStyles.aHeight || ''; imgA.style.maxWidth = imgBox.__originalStyles.aMaxWidth || ''; img.style.width = imgBox.__originalStyles.imgWidth || ''; img.style.height = imgBox.__originalStyles.imgHeight || ''; img.style.maxWidth = imgBox.__originalStyles.imgMaxWidth || ''; img.style.maxHeight = imgBox.__originalStyles.imgMaxHeight || ''; img.style.transform = imgBox.__originalStyles.imgTransform || ''; img.style.position = imgBox.__originalStyles.imgPosition || ''; img.style.top = imgBox.__originalStyles.imgTop || ''; img.style.left = imgBox.__originalStyles.imgLeft || ''; img.style.display = imgBox.__originalStyles.imgDisplay || ''; img.style.marginTop = imgBox.__originalStyles.imgMarginTop || ''; img.style.marginLeft = imgBox.__originalStyles.imgMarginLeft || ''; img.style.marginRight = imgBox.__originalStyles.imgMarginRight || ''; img.style.marginBottom = imgBox.__originalStyles.imgMarginBottom || ''; imgBox.style.float = imgBox.__originalStyles.boxFloat || ''; imgA.style.float = imgBox.__originalStyles.aFloat || ''; img.style.float = imgBox.__originalStyles.imgFloat || ''; if (imgBox.__originalStyles.imgAlign == null) img.removeAttribute('align'); else img.setAttribute('align', imgBox.__originalStyles.imgAlign); if (imgBox.__originalStyles.imgHspace == null) img.removeAttribute('hspace'); else img.setAttribute('hspace', imgBox.__originalStyles.imgHspace); delete imgBox.__originalStyles; delete imgBox.__sizeCache; delete imgBox.__maxWidth; } // ★ 未激活但存在图片时,消息容器宽度扩大 100px,且不超过场景最大宽度 // const msgMain = imgBox.closest('.h-threads-item-reply-main'); // if (msgMain) { // const currentWidth = msgMain.offsetWidth; // const maxMsgWidth = this.getMaxMsgWidth(msgMain); // const targetWidth = Math.min(currentWidth + 50, maxMsgWidth); // msgMain.style.width = targetWidth + 'px'; // } // ★ 新增逻辑:恢复页面加载时的额外拓展 // const msgMain = imgBox.closest('.h-threads-item-reply-main'); // if (msgMain && msgMain.__originalWidth !== undefined) { // msgMain.style.width = msgMain.__originalWidth; // 恢复原始宽度 // delete msgMain.__originalWidth; // } return; } // 保存原始样式 if (!imgBox.__originalStyles) { imgBox.__originalStyles = { aWidth: imgA.style.width, aHeight: imgA.style.height, aMaxWidth: imgA.style.maxWidth, imgWidth: img.style.width, imgHeight: img.style.height, imgMaxWidth: img.style.maxWidth, imgMaxHeight: img.style.maxHeight, imgTransform: img.style.transform, imgPosition: img.style.position, imgTop: img.style.top, imgLeft: img.style.left, imgDisplay: img.style.display, imgMarginTop: img.style.marginTop, imgMarginLeft: img.style.marginLeft, imgMarginRight: img.style.marginRight, imgMarginBottom: img.style.marginBottom, boxFloat: imgBox.style.float, aFloat: imgA.style.float, imgFloat: img.style.float, imgAlign: img.getAttribute('align'), imgHspace: img.getAttribute('hspace') }; } if (imgBox.__activeLayoutFrame) cancelAnimationFrame(imgBox.__activeLayoutFrame); imgBox.__activeLayoutForceRecalculate = !!(imgBox.__activeLayoutForceRecalculate || forceRecalculate); imgBox.__activeLayoutFrame = requestAnimationFrame(() => { const shouldForceRecalculate = !!imgBox.__activeLayoutForceRecalculate; imgBox.__activeLayoutFrame = 0; imgBox.__activeLayoutForceRecalculate = false; if (!imgBox.classList.contains('h-active')) return; const naturalWidth = img.naturalWidth; const naturalHeight = img.naturalHeight; if (!naturalWidth || !naturalHeight) { if (img.complete) return; img.onload = () => { if (imgBox.classList.contains('h-active')) this.handleActiveImageBox(imgBox); }; return; } // 强制触发布局更新 document.body.offsetHeight; // 获取当前最大可用宽度 // const container = imgBox.closest('.h-threads-item-reply-main'); const container = imgBox.closest('.h-threads-item-reply-main') || imgBox.closest('.h-threads-item-main'); let maxWidth; if (container) { container.offsetHeight; // 触发 reflow let msgWidth = container.offsetWidth || container.clientWidth; // 新增:识别预览框与浮窗场景 const isPreview = !!imgBox.closest('.h-preview-box'); const isOverlay = !!imgBox.closest('.qp-body'); // 回复浮窗 if (isPreview) { if (isOverlay) { // 在预览框 + 浮窗中:使用浮窗内容容器宽度 const quoteBox = imgBox.closest('.qp-quote'); if (quoteBox) { maxWidth = this.getElementContentWidth(quoteBox); } else { const wrapEl = imgBox.closest('.qp-content-wrap'); if (wrapEl) { msgWidth = wrapEl.offsetWidth || msgWidth; } // 留边距 40(与原逻辑一致),不再受 window.innerWidth - 240 限制 maxWidth = Math.max(0, (msgWidth || 0) - 40); } } else { // 在预览框但不在浮窗中:最大宽度限制为 800 // 仍保留消息容器边距 40 const base = Math.max(0, (msgWidth || 0) - 40); maxWidth = Math.min(base, 800); } } else { // 常规消息:保留原逻辑 if (msgWidth < 300) { const parentContainer = container.closest('.h-threads-item-replies') || container.closest('.h-threads-list') || container.parentElement; if (parentContainer) { msgWidth = parentContainer.offsetWidth || window.innerWidth - 240; } else { msgWidth = window.innerWidth - 240; } } // maxWidth = Math.min(msgWidth - 40, handleImageLayout.getMaxMsgWidth(container)); maxWidth = handleImageLayout.getMaxMsgWidth(container); } } else { // 无容器时,兜底用视口宽度 maxWidth = Math.min(window.innerWidth - 240, 1200); } // 如果最大宽度变化或首次计算,重新预计算所有旋转角度的尺寸 if (!imgBox.__sizeCache || shouldForceRecalculate || imgBox.__maxWidth !== maxWidth) { imgBox.__maxWidth = maxWidth; imgBox.__sizeCache = this.precalculateImageSizes(naturalWidth, naturalHeight, maxWidth); } // 获取当前旋转索引 const rotateIndex = parseInt(img.dataset.rotateIndex || '0'); // 应用对应旋转角度的尺寸 this.applyImageSize(imgBox, rotateIndex); }); }, // 处理普通元素溢出 handleGeneralElements(container) { const SELECTORS = [ '.h-threads-content', '.h-threads-item-reply-main', '.h-preview-box', 'pre', 'code', 'table' ]; SELECTORS.forEach(selector => { container.querySelectorAll(selector).forEach(el => { const rect = el.getBoundingClientRect(); if (rect.right > window.innerWidth) { el.style.maxWidth = '100%'; el.style.boxSizing = 'border-box'; } if (el.tagName === 'PRE' || el.tagName === 'CODE') { el.style.maxWidth = '100%'; el.style.overflowX = 'auto'; el.style.whiteSpace = 'pre-wrap'; } if (el.tagName === 'TABLE') { el.style.maxWidth = '100%'; el.style.display = 'block'; el.style.overflowX = 'auto'; } }); }); container.querySelectorAll('.h-threads-content a, .h-preview-box a').forEach(el => { const href = el.getAttribute('href') || ''; if (href.length > 50) { el.style.wordBreak = 'break-all'; el.style.overflowWrap = 'break-word'; } }); container.querySelectorAll('.h-threads-content img:not(.h-threads-img), .h-preview-box img').forEach(el => { el.style.maxWidth = '100%'; el.style.height = 'auto'; }); } }; // ==================== 子函数2: 交互逻辑处理 ==================== const handleImageInteraction = { // 记录点击次数(用于切换展开/收起) clickCountMap: new WeakMap(), lastClickedAnchor: null, // 准备高清地址,实际图片请求交给近视窗懒加载。 replaceHDLinks(container) { const imgs = []; if (container.matches && container.matches('img')) imgs.push(container); container.querySelectorAll('img').forEach(img => imgs.push(img)); imgs.forEach(img => { if (img.closest('.h-preview-box')) return; // 新增:预览框中跳过 const src = img.currentSrc || img.src || img.getAttribute('src') || ''; if (src.includes('/thumb/')) { const hdSrc = hdImageLazyLoader.prepare(img, src); if (hdImageLazyLoader.isGifUrl(hdSrc)) hdImageLazyLoader.load(img, hdSrc); } }); const anchors = []; if (container.matches && container.matches('a')) anchors.push(container); container.querySelectorAll('a').forEach(a => anchors.push(a)); anchors.forEach(a => { if (a.closest('.h-preview-box')) return; // 新增:预览框中跳过 if (a.href.includes('/thumb/')) { a.href = hdImageLazyLoader.getHdUrl(a.href); } }); }, // 绑定图片点击展开/收起逻辑 bindImageClickLogic(container) { container.querySelectorAll('.h-threads-img-a').forEach(anchor => { // ===== 新增:预览框内的图片不绑定点击逻辑 ===== if (anchor.closest('.h-preview-box')) return; if (anchor.dataset.hdBound === "1") return; anchor.dataset.hdBound = "1"; anchor.addEventListener('click', (e) => { e.preventDefault(); e.stopImmediatePropagation(); e.stopPropagation(); const box = anchor.closest('.h-threads-img-box'); if (!box) return; const img = box.querySelector('.h-threads-img'); if (!img) return; if (!box.__initialDisplayState) { box.__initialDisplayState = { src: img.currentSrc || img.src || '', href: anchor.href || '', aWidth: anchor.style.width || '', aHeight: anchor.style.height || '', imgWidth: img.style.width || '', imgHeight: img.style.height || '', imgNaturalWidth: img.width || 0, imgNaturalHeight: img.height || 0 }; } const shouldOpen = !box.classList.contains('h-active'); // 同步保留旧计数状态,但展开/收起以真实 DOM 状态为准。 if (this.lastClickedAnchor !== anchor) { this.lastClickedAnchor = anchor; this.clickCountMap.set(anchor, 0); } this.clickCountMap.set(anchor, shouldOpen ? 1 : 0); if (shouldOpen) { // 展开 box.classList.add('h-active'); hdImageLazyLoader.load(img, anchor.href); img.dataset.rotateIndex = '0'; // 重置旋转索引 // ★ 新增:加载失败兜底 img.onerror = () => { console.warn('[图片加载失败,回退到缩略图]', img.src); const fallback = img.dataset.xdexThumbSrc || img.getAttribute('data-src') || anchor.href; img.src = fallback; }; // 触发布局计算 handleImageLayout.handleActiveImageBox(box); } else { // 收起 box.classList.remove('h-active'); img.style.transform = ''; img.dataset.rotateIndex = '0'; const initial = box.__initialDisplayState; if (initial) { if (initial.src) img.src = initial.src; if (initial.href) anchor.href = initial.href; anchor.style.width = initial.aWidth || ''; anchor.style.height = initial.aHeight || ''; img.style.width = initial.imgWidth || ''; img.style.height = initial.imgHeight || ''; if (!initial.imgWidth && initial.imgNaturalWidth) { img.width = initial.imgNaturalWidth; } if (!initial.imgHeight && initial.imgNaturalHeight) { img.height = initial.imgNaturalHeight; } } // 触发布局恢复 handleImageLayout.handleActiveImageBox(box); } }, true); }); }, // 绑定图片控件(收起/旋转按钮) bindImageControls(container) { container.querySelectorAll('.h-threads-img-box').forEach(box => { // ===== 新增:预览框内的图片不绑定控件逻辑 ===== if (box.closest('.h-preview-box')) return; if (box.dataset.toolBound === "1") return; box.dataset.toolBound = "1"; const img = box.querySelector('.h-threads-img'); const imgA = box.querySelector('.h-threads-img-a'); const toolSmall = box.querySelector('.h-threads-img-tool-small'); const toolLeft = box.querySelector('.h-threads-img-tool-left'); const toolRight = box.querySelector('.h-threads-img-tool-right'); // 收起按钮 if (toolSmall && imgA) { toolSmall.addEventListener('click', (e) => { e.preventDefault(); e.stopImmediatePropagation(); imgA.click(); }); } // 左旋按钮(逆时针) if (toolLeft && img) { toolLeft.addEventListener('click', (e) => { e.preventDefault(); e.stopImmediatePropagation(); if (!box.classList.contains('h-active')) return; let rotateIndex = parseInt(img.dataset.rotateIndex || '0'); //rotateIndex = (rotateIndex - 1 + 4) % 4; rotateIndex = rotateIndex - 1; // ★ 改为累加,不取模 img.dataset.rotateIndex = rotateIndex.toString(); console.log('[左旋] rotateIndex:', rotateIndex); // 延迟执行布局调整,等待旋转动画完成 setTimeout(() => { handleImageLayout.applyImageSize(box, rotateIndex); }, 50); }); } // 右旋按钮(顺时针) if (toolRight && img) { toolRight.addEventListener('click', (e) => { e.preventDefault(); e.stopImmediatePropagation(); if (!box.classList.contains('h-active')) return; let rotateIndex = parseInt(img.dataset.rotateIndex || '0'); //rotateIndex = (rotateIndex + 1) % 4; rotateIndex = rotateIndex + 1; // ★ 改为累加,不取模 img.dataset.rotateIndex = rotateIndex.toString(); console.log('[右旋] rotateIndex:', rotateIndex); // 延迟执行布局调整,等待旋转动画完成 setTimeout(() => { handleImageLayout.applyImageSize(box, rotateIndex); }, 50); }); } }); }, // 监听图片盒子的状态变化 observeImageBoxes(container) { const imgBoxes = container.querySelectorAll('.h-threads-img-box'); imgBoxes.forEach(imgBox => { // ===== 新增:预览框内的图片不观察 ===== if (imgBox.closest('.h-preview-box')) return; // 如果已经激活但没有正确的尺寸,立即处理 if (imgBox.classList.contains('h-active')) { const imgA = imgBox.querySelector('.h-threads-img-a'); if (imgA) { const currentWidth = parseInt(imgA.style.width) || 0; const currentHeight = parseInt(imgA.style.height) || 0; // 如果容器尺寸异常(太小或为0),强制重新计算 if (currentWidth < 50 || currentHeight < 50) { requestAnimationFrame(() => { if (imgBox.classList.contains('h-active')) { handleImageLayout.handleActiveImageBox(imgBox, true); } }); } } } // 监听 class 变化 if (!imgBox.__overflowObserver) { // ★ 计数埋点:每个带图回复盒一个(单图复杂度),数量应≈带图回复数且不随滚动/翻页重复创建 startupPerfDebug.record('observer.create:hdImageBox', 0, { tag: 'img-box' }); const observer = new MutationObserver(mutations => { startupPerfDebug.measureObserver('hdImageBox', mutations, () => { mutations.forEach(mutation => { if (mutation.attributeName === 'class') { handleImageLayout.handleActiveImageBox(imgBox); // ★ 图片收起(h-active 被移除)后补一次首次判定:展开态下首次判定会被跳过, // 而收起时补写容器宽度的旧逻辑(约 9517-9530,注释掉的死代码)缺失,缺这一步该回复将永久不加宽。 // 已参与过加宽的回复无需重算:预算已在首次判定时一次算入后续变化的最坏增量, // min-width 语义下收起后内容回落只会让表格回到该宽度,不会突破。 // 复用该图片盒已有的 class observer,事件驱动,不做周期性扫描。 if (!imgBox.classList.contains('h-active')) { const replyMain = imgBox.closest('.h-threads-item-reply-main'); if (replyMain && replyMain.__imageWidthExpanded !== true) { handleImageLayout.expandMsgWidthIfImageExists(replyMain); } } } }); }, () => ({ imgBoxes: 1 })); }); observer.observe(imgBox, { attributes: true, childList: true, subtree: true }); imgBox.__overflowObserver = observer; } }); } }; function expandReplyMainWidths(scope) { const replyMains = []; if (scope.matches && scope.matches('.h-threads-item-reply-main')) { replyMains.push(scope); } if (scope.querySelectorAll) { scope.querySelectorAll('.h-threads-item-reply-main').forEach(msgMain => { replyMains.push(msgMain); }); } // ★ 向上取证:pending 标记常落在 reply-main 的后代(.h-threads-img-box / .h-threads-img-a / // .h-threads-img / .h-threads-content)上,仅向下查找会漏掉所属 reply-main(该回复永远不加宽) const upwardMain = scope.closest && scope.closest('.h-threads-item-reply-main'); if (upwardMain && upwardMain !== scope) { replyMains.push(upwardMain); } replyMains.forEach(msgMain => { handleImageLayout.expandMsgWidthIfImageExists(msgMain); }); } // ==================== 执行所有处理 ==================== // 1. 替换高清链接 handleImageInteraction.replaceHDLinks(root); hdImageLazyLoader.observe(root); // 2. 绑定图片点击和控件 handleImageInteraction.bindImageClickLogic(root); handleImageInteraction.bindImageControls(root); if (typeof enableImageContextMenu === 'function') enableImageContextMenu(root); // 3. 处理普通元素溢出 handleImageLayout.handleGeneralElements(root); // 4. 监听图片盒子状态 handleImageInteraction.observeImageBoxes(root); // ★ 页面加载和无缝翻页新内容都需要拓展带图回复宽度 // 拓展基数=信息行宽,会被 hideEmptyTitleAndEmail 改变约97px;必须保证同一 root 内先 hide 再量宽, // 否则 document级/引用浮窗/pending 等路径读到 hide 前宽度,与 applyPageEnhancements 路径得到两种结果(竞态) try { const _cfg = getEarlyStartupConfig(); if (_cfg.hideEmptyTitleEmail && typeof hideEmptyTitleAndEmail === 'function') hideEmptyTitleAndEmail(root); } catch (e) {} expandReplyMainWidths(root); // 注:后续增强(相对时间/饼干标记等)造成的宽度变化已按最坏增量算入加宽预算(getExpandedWidthBudget), // 一次写入后不再重算,避免渲染后的反复横向变化。 // ==================== 全局监听 ==================== // 监听 DOM 变化 if (root === document && !enableHDImageAndLayoutFix.__globalObserver) { // ★ 计数埋点:全局 DOM 观察器必须恒为 1,不得随增量刷新/翻页重复创建 startupPerfDebug.record('observer.create:hdGlobal', 0, { tag: 'hd-global' }); const observer = new MutationObserver(mutations => { startupPerfDebug.measureObserver('hdGlobal', mutations, () => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType !== 1 || !node.dataset) return; if (node.closest && node.closest('#sp_panel')) return; const isRelevant = node.matches?.('.h-threads-img-box, .h-threads-img-a, .h-threads-img, .h-threads-content, .h-preview-box') || node.querySelector?.('.h-threads-img-box, .h-threads-img-a, .h-threads-img, .h-threads-content, .h-preview-box'); if (isRelevant) node.dataset.xdexHdLayoutPending = '1'; }); }); schedulePendingHDImageAndLayoutFix(); }, () => startupPerfDebug.summarizeRoot(document)); }); observer.observe(document.body, { childList: true, subtree: true }); enableHDImageAndLayoutFix.__globalObserver = observer; } // 监听窗口大小改变 if (root === document && !enableHDImageAndLayoutFix.__resizeHandlerBound) { window.addEventListener('resize', () => { // 一帧只处理一次:拖动窗口时 resize 每秒可触发数十次,每次都全文档处理图片布局 const run = () => { enableHDImageAndLayoutFix.__resizeFrameId = 0; handleImageLayout.handleGeneralElements(document); document.querySelectorAll('.h-threads-img-box.h-active').forEach(imgBox => { handleImageLayout.handleActiveImageBox(imgBox, true); }); // ★ 补跑全量加宽(QwQNT 时序自愈):初始 pass 可能因 innerWidth 未稳定 // (QQ↔nmbxd 切换、窗口还原时序)走到 targetWidth<=0 静默返回且不置锁, // 该分支无任何兜底;resize 补跑借 __imageWidthExpanded 锁对已加宽者 O(1) 跳过 try { expandReplyMainWidths(document); } catch (e) {} }; if (enableHDImageAndLayoutFix.__resizeFrameId) return; if (typeof requestAnimationFrame !== 'function') { run(); return; } enableHDImageAndLayoutFix.__resizeFrameId = requestAnimationFrame(run); }); enableHDImageAndLayoutFix.__resizeHandlerBound = true; } // ★ 供驻留/可见性链路补跑加宽(QwQNT 时序自愈):expandReplyMainWidths 是本函数内部实现, // 外部(visibilitychange 监听器)无法直接访问,挂到函数属性上桥接; // document 级首次 pass 时执行到这里,此后外部即可通过可选链调用 if (root === document) enableHDImageAndLayoutFix.expandReplyMainWidths = expandReplyMainWidths; }, () => startupPerfDebug.summarizeRoot(root)); } function handlePendingHDImageAndLayoutFix(root = document) { if (!root || !root.querySelectorAll) return; const queue = handlePendingHDImageAndLayoutFix.__queue || []; const queued = handlePendingHDImageAndLayoutFix.__queued || new Set(); handlePendingHDImageAndLayoutFix.__queue = queue; handlePendingHDImageAndLayoutFix.__queued = queued; root.querySelectorAll('[data-xdex-hd-layout-pending="1"]').forEach(node => { delete node.dataset.xdexHdLayoutPending; if (queued.has(node)) return; queued.add(node); queue.push(node); }); handlePendingHDImageAndLayoutFix.__scheduled = false; if (handlePendingHDImageAndLayoutFix.__running || !queue.length) return; processPendingHDImageAndLayoutFix(root); } function processPendingHDImageAndLayoutFix(root = document) { const queue = handlePendingHDImageAndLayoutFix.__queue || []; const queued = handlePendingHDImageAndLayoutFix.__queued || new Set(); const batchSize = 5; handlePendingHDImageAndLayoutFix.__running = true; startupPerfDebug.measure('enableHDImageAndLayoutFix.pending', () => { let processed = 0; while (processed < batchSize && queue.length) { const node = queue.shift(); queued.delete(node); if (node && node.isConnected) { // ★ 标记常落在 reply-main 的后代(图片盒/锚/图片/正文)上:直接以该后代为 root 会导致 // 其所属 reply-main 不在作用域内(既不加宽、也绑不上图片控件),故提升到最近的整条回复 const scope = (node.closest && node.closest('.h-threads-item-reply')) || node; enableHDImageAndLayoutFix(scope); processed++; } } }, () => startupPerfDebug.summarizeRoot(root)); if (queue.length) { setTimeout(() => processPendingHDImageAndLayoutFix(root), 0); return; } handlePendingHDImageAndLayoutFix.__running = false; } function schedulePendingHDImageAndLayoutFix() { if (handlePendingHDImageAndLayoutFix.__scheduled) return; handlePendingHDImageAndLayoutFix.__scheduled = true; setTimeout(() => handlePendingHDImageAndLayoutFix(document), 0); } // 旧逻辑,只作用于预览框 function enableHDImage(root = document) { return startupPerfDebug.measure('enableHDImage', () => { // 记录点击次数(用于切换展开/收起) const clickCountMap = new WeakMap(); let lastClickedAnchor = null; // 1. 仅在预览框内准备高清链接,实际请求交给近视窗懒加载 root.querySelectorAll('.h-preview-box img').forEach(img => { const src = img.currentSrc || img.src || img.getAttribute('src') || ''; if (src.includes('/thumb/')) { const hdSrc = hdImageLazyLoader.prepare(img, src); if (hdImageLazyLoader.isGifUrl(hdSrc)) hdImageLazyLoader.load(img, hdSrc); } }); root.querySelectorAll('.h-preview-box a').forEach(a => { if (a.href.includes('/thumb/')) { a.href = hdImageLazyLoader.getHdUrl(a.href); } }); hdImageLazyLoader.observe(root); // 2. 仅绑定预览框内的图片盒子的点击逻辑 root.querySelectorAll('.h-preview-box .h-threads-img-a').forEach(anchor => { if (anchor.dataset.hdBound === "1") return; anchor.dataset.hdBound = "1"; anchor.addEventListener('click', function (e) { e.preventDefault(); // 保护:如果不是预览框中的 anchor,直接返回 if (!this.closest('.h-preview-box')) return; const box = this.closest('.h-threads-img-box'); if (!box) return; const img = box.querySelector('.h-threads-img'); if (!img) return; // 计数切换展开/收起 if (lastClickedAnchor !== this) { lastClickedAnchor = this; clickCountMap.set(this, 0); } let count = (clickCountMap.get(this) || 0) + 1; clickCountMap.set(this, count); if (count % 2 === 1) { box.classList.add('h-active'); hdImageLazyLoader.load(img, this.href); updatePreviewImageLayout(box); } else { box.classList.remove('h-active'); img.style.transform = ''; img.style.top = '0px'; img.style.left = '0px'; img.dataset.rotateIndex = 0; const ds = img.getAttribute('data-src'); if (ds) img.src = ds; else if (img.dataset.xdexThumbSrc) img.src = img.dataset.xdexThumbSrc; updatePreviewImageLayout(box); } }); }); // 3. 工具按钮逻辑(收起/旋转) const rotateArray = [ 'matrix(1, 0, 0, 1, 0, 0)', 'matrix(0, 1, -1, 0, 0, 0)', 'matrix(-1, 0, 0, -1, 0, 0)', 'matrix(0, -1, 1, 0, 0, 0)' ]; function applyResizeForRotation(img, imgA, rotateIndex) { if (!img || !imgA) return; const normalizedRotateIndex = ((rotateIndex % rotateArray.length) + rotateArray.length) % rotateArray.length; img.style.setProperty('transform', rotateArray[normalizedRotateIndex], 'important'); img.style.setProperty('transform-origin', 'center center', 'important'); const width = img.width; const height = img.height; if (normalizedRotateIndex === 1 || normalizedRotateIndex === 3) { const offset = (width - height) / 2; img.style.setProperty('top', offset + 'px', 'important'); img.style.setProperty('left', -offset + 'px', 'important'); imgA.style.height = width + 'px'; } else { img.style.setProperty('top', '0px', 'important'); img.style.setProperty('left', '0px', 'important'); imgA.style.height = height + 'px'; } } function updatePreviewImageLayout(box) { if (!box) return; const img = box.querySelector('.h-threads-img'); const imgA = box.querySelector('.h-threads-img-a'); if (!img || !imgA) return; const rotateIndex = Number.parseInt(img.dataset.rotateIndex || '0', 10) || 0; applyResizeForRotation(img, imgA, rotateIndex); } function rotatePreviewImage(box, delta) { if (!box || !box.closest('.h-preview-box')) return; const img = box.querySelector('.h-threads-img'); const imgA = box.querySelector('.h-threads-img-a'); if (!img || !imgA) return; if (!box.classList.contains('h-active')) { box.classList.add('h-active'); hdImageLazyLoader.load(img, imgA.getAttribute('href') || img.src || ''); } const current = Number.parseInt(img.dataset.rotateIndex || '0', 10) || 0; const next = (current + delta + rotateArray.length) % rotateArray.length; img.dataset.rotateIndex = String(next); updatePreviewImageLayout(box); requestAnimationFrame(() => updatePreviewImageLayout(box)); } // 3. 仅绑定预览框内的图片盒子工具按钮 root.querySelectorAll('.h-preview-box .h-threads-img-box').forEach(box => { if (box.dataset.toolBound === "1") return; box.dataset.toolBound = "1"; // 保护:如果该盒子不在预览框,则跳过 if (!box.closest('.h-preview-box')) return; const img = box.querySelector('.h-threads-img'); const imgA = box.querySelector('.h-threads-img-a'); const toolSmall = box.querySelector('.h-threads-img-tool-small'); const toolLarge = box.querySelector('.h-threads-img-tool-large'); const toolLeft = box.querySelector('.h-threads-img-tool-left'); const toolRight = box.querySelector('.h-threads-img-tool-right'); if (toolSmall && imgA) { toolSmall.addEventListener('click', (e) => { e.preventDefault(); e.stopImmediatePropagation(); imgA.click(); }); } if (toolLarge && imgA) { toolLarge.href = imgA.href || img.src || 'javascript:;'; toolLarge.target = '_blank'; toolLarge.addEventListener('click', (e) => { e.stopImmediatePropagation(); const url = imgA.href || img.src || ''; if (!url || url === location.href || /(^|\/)?:javascript:;?$/.test(url)) { e.preventDefault(); return; } toolLarge.href = url; }); } if (toolLeft && img) { toolLeft.addEventListener('click', (e) => { e.preventDefault(); e.stopImmediatePropagation(); rotatePreviewImage(box, -1); }); } if (toolRight && img) { toolRight.addEventListener('click', (e) => { e.preventDefault(); e.stopImmediatePropagation(); rotatePreviewImage(box, 1); }); } }); }, () => startupPerfDebug.summarizeRoot(root)); } // 板块页链接新标签页打开 function runLinkBlank(root = document) { const selector = '#h-content .h-threads-list a, .margin-bottom a, .margin-top a, [style*="margin-top: -5px"] a, [style*="margin-top:-5px"] a, #h-menu-content a'; root.querySelectorAll(selector).forEach(a => { // ===== 新增:排除分页导航内的链接 ===== if (a.closest('ul.uk-pagination.uk-pagination-left.h-pagination')) return; if (a.closest('.uk-parent')) return; const href = String(a.getAttribute('href') || '').trim(); if (!href || href === '#' || /^javascript:/i.test(href)) return; // ===================================== a.setAttribute('target', '_blank'); if (!a.dataset.xdexLinkBlankCurrentBound) { a.dataset.xdexLinkBlankCurrentBound = '1'; a.addEventListener('click', e => { if (e.button !== 0 || !(e.ctrlKey || e.metaKey)) return; e.preventDefault(); window.location.href = a.href; }); } }); } /* -------------------------------------------------- * tag 9. 引用浮窗/鼠标离开后自动隐藏原生引用/引用格式拓展 * -------------------------------------------------- */ function enableQuotePreview() { if (enableQuotePreview.__initialized) return; const quotePreviewRoot = document.body; if (!quotePreviewRoot) { setTimeout(enableQuotePreview, 25); return; } const cache = Object.create(null); // 防止短时间内重复点击同一处引用号导致多重弹窗 let lastQuoteEl = null; let lastQuoteAt = 0; const QUOTE_DOUBLE_CLICK_WINDOW = 250; // 注入样式(只注入一次) if (!document.getElementById('qp-styles')) { const style = document.createElement('style'); style.id = 'qp-styles'; style.textContent = ` .qp-overlay-quote { position: fixed; inset: 0; /* 10004:高于导出串弹窗(10001)与设置面板(9999) —— 弹窗内点串号时引用浮窗必须盖在弹窗上 */ z-index: 10004; background: rgba(0,0,0,.45); display: none; } .qp-stack { position: fixed; top: 55%; /* 下移一点 */ left: 50%; transform: translate(-50%, -50%); width: min(90vw, 820px); height: 80vh; overflow: visible; box-sizing: border-box; } .qp-close-all { position: fixed; right: 12px; bottom: 12px; width: 30px; height: 30px; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: var(--xdex-qp-reset-bg, rgba(0,0,0,.6)); border: 1px solid var(--xdex-qp-reset-border, rgba(255,255,255,.35)); border-radius: 8px; cursor: pointer; z-index: 10005; /* 高于 .qp-overlay-quote(10004) */ user-select: none; color: var(--xdex-qp-reset-color, #fff); } .qp-close-all:hover { border-color: #EE0000 !important; color: #EE0000 !important; } /* 两个刷新钮(归位/局部刷新):悬浮边框与 svg 图标同步变绿 */ .qp-reset-btn.xdex-icon-btn:hover, .seamless-refresh-btn.xdex-icon-btn:hover { border-color: #00ffcc !important; color: #00ffcc !important; } .qp-reset-btn.xdex-icon-btn:hover svg, .seamless-refresh-btn.xdex-icon-btn:hover svg { stroke: #00ffcc !important; } .qp-overlay-quote .qp-quote { position: absolute; top: 0; left: 0; width: 100%; max-height: 100%; overflow: auto; /* 走 darkreader 兼容 token,避免写死 #FFFFEE */ background: var(--xdex-qp-shell-bg, #FFFFEE); border: 1px solid var(--xdex-qp-border, #ccc); outline: 2px solid var(--xdex-qp-outline, #fff); border-radius: 8px; box-shadow: 0 8px 24px var(--xdex-qp-shadow, rgba(0,0,0,.24)); padding: 18px 20px 20px; box-sizing: border-box; } .qp-quote * { max-width: 100%; box-sizing: border-box; } .qp-header { position: sticky; top: 0; display: flex; gap: 10px; justify-content: flex-end; padding-bottom: 6px; margin: -8px -8px 10px; padding: 8px; background: transparent; /* ✅ 改为透明背景 */ z-index: 2; } .qp-overlay-quote .qp-level { font-size: 12px; color: inherit; background: color-mix(in srgb, var(--xdex-qp-border, #ccc) 35%, transparent); border-radius: 4px; padding: 2px 6px; } .qp-overlay-quote .qp-back { font-size: 12px; color: inherit; background: color-mix(in srgb, var(--xdex-qp-border, #ccc) 22%, transparent); border: 1px solid var(--xdex-qp-border, #ccc); border-radius: 4px; padding: 2px 6px; cursor: pointer; } .qp-overlay-quote .qp-jump { font-size: 12px; color: inherit; background: color-mix(in srgb, var(--xdex-qp-border, #ccc) 22%, transparent); border: 1px solid var(--xdex-qp-border, #ccc); border-radius: 4px; padding: 2px 6px; cursor: pointer; } .qp-overlay-quote .qp-quote.is-dragging, .qp-overlay-quote .qp-quote.is-resizing { cursor: grabbing !important; user-select: none !important; } /* 引用浮窗四角拉伸(与回复浮窗一致:角=缩放,边=拖动) */ .qp-overlay-quote .qp-resize-corner { position: absolute; width: 14px; height: 14px; z-index: 5; pointer-events: auto; background: transparent; } .qp-overlay-quote .qp-resize-corner.nw { top: 0; left: 0; cursor: nwse-resize; } .qp-overlay-quote .qp-resize-corner.ne { top: 0; right: 0; cursor: nesw-resize; } .qp-overlay-quote .qp-resize-corner.sw { bottom: 0; left: 0; cursor: nesw-resize; } .qp-overlay-quote .qp-resize-corner.se { bottom: 0; right: 0; cursor: nwse-resize; } .qp-overlay-quote .qp-drag-edge.top { top: 0; left: 14px; right: 14px; height: 10px; } .qp-overlay-quote .qp-drag-edge.bottom { bottom: 0; left: 14px; right: 14px; height: 10px; } .qp-overlay-quote .qp-drag-edge.left { top: 14px; bottom: 14px; left: 0; width: 10px; } .qp-overlay-quote .qp-drag-edge.right { top: 14px; bottom: 14px; right: 0; width: 10px; } #h-ref-view { pointer-events: none !important; } #h-ref-view { z-index: 20000 !important; /* 保证原生引用框在浮窗之上 */ } /* 拖拽手柄:四边窄条,仅在手柄区域显示“移动”指针 */ .qp-drag-edge { position: absolute; pointer-events: auto; z-index: 3; cursor: move; } .qp-drag-edge.top { top: 0; left: 0; right: 0; height: 10px; } .qp-drag-edge.bottom { bottom: 0; left: 0; right: 0; height: 10px; } .qp-drag-edge.left { top: 0; bottom: 0; left: 0; width: 10px; } .qp-drag-edge.right { top: 0; bottom: 0; right: 0; width: 10px; } /* 标题栏作为拖拽手柄时的指针反馈 */ .qp-header { cursor: move; } `; document.head.appendChild(style); } //✅ 监听原生引用浮窗的显示,如果鼠标不在引用号上则立即隐藏 const observer = new MutationObserver(() => { const refView = document.getElementById('h-ref-view'); if (!refView) return; const display = refView.style.display; // ✅ 当浮窗被隐藏时(none),重置透明度 if (display === 'none') { refView.style.opacity = ''; return; } // ✅ 当浮窗显示时(block),检查鼠标是否在引用号上 if (display === 'block') { const quoteFonts = document.querySelectorAll('font[color="#789922"]'); let isHovering = false; quoteFonts.forEach(font => { if (font.matches(':hover')) { isHovering = true; } }); // ✅ 显示但鼠标不在引用号上 → 立即隐藏并重置透明度 if (!isHovering) { refView.style.display = 'none'; refView.style.opacity = ''; } } }); observer.observe(quotePreviewRoot, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] }); const $overlay = $('<div class="qp-overlay-quote"></div>').appendTo('body'); const $stack = $('<div class="qp-stack"></div>').appendTo($overlay); const $closeAll= $('<div class="qp-close-all" title="关闭所有引用浮窗"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="red" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path style="fill:none" d="M6 6l12 12"/><path style="fill:none" d="M18 6L6 18"/></svg></div>').appendTo($overlay); $closeAll.on('click', () => { $stack.children('.qp-quote').each((_, q) => clearQuoteSourceMark($(q))); $stack.empty(); $overlay.fadeOut(160); }); // 点击引用框以外的任何地方都关闭 $overlay.off('click.qp').on('click.qp', function(e){ if (!$overlay.is(':visible')) return; const $top = $stack.children('.qp-quote').last(); if (!$top.length) { $overlay.fadeOut(160); return; } // 1) 如果点击发生在最上层引用框内部,忽略 // 必须用派发时定格的 composedPath 判定:冒泡途中控件若替换了自身内容,e.target 会脱离文档, // closest($top) 随即失效、被误判成「点了框外」而误关引用浮窗(jQuery 事件需取 originalEvent) const nativeEv = e.originalEvent || e; const evPath = typeof nativeEv.composedPath === 'function' ? nativeEv.composedPath() : null; const inTopQuote = evPath ? evPath.indexOf($top[0]) !== -1 : !!(e.target && e.target.closest && e.target.closest($top[0])); if (inTopQuote) return; // 2) 如果正在/刚刚拖拽或拉伸,避免误触关闭 if ($top.hasClass('is-dragging') || $top.hasClass('is-resizing') || $('.qp-quote.is-dragging').length || $('.qp-quote.is-resizing').length || $overlay.data('isDragging')) return; // 3) 点击最上层框之外:仅移除最上层 clearQuoteSourceMark($top); $top.remove(); if ($stack.children('.qp-quote').length === 0) $overlay.fadeOut(160); }); $(document).on('keydown.qp', e => { if (e.key !== 'Escape' || !$overlay.is(':visible')) return; const $last = $stack.children('.qp-quote').last(); if ($last.length) { clearQuoteSourceMark($last); $last.remove(); } if ($stack.children().length === 0) $overlay.fadeOut(160); }); // 浮窗关闭时清除源引用元素上的“已打开”标记,允许之后再次点击打开 function clearQuoteSourceMark($quote) { try { const el = $quote && $quote[0] && $quote[0].__xdexSourceEl; if (el) { el.__xdexQuoteOpen = false; el.__xdexQuotePending = false; } } catch (e) {} } function fetchData(tid) { if (cache[tid]) return Promise.resolve(cache[tid]); return $.get(`/Home/Forum/ref?id=${tid}`).then(html => (cache[tid] = html)); } function stripIds($root) { $root.find('[id]').removeAttr('id'); return $root; } function simplifyQuoteInfoIdLinks($root) { if (!$root || !$root.find) return; $root.find('a.h-threads-info-id[href]').each(function () { const a = this; const href = a.getAttribute('href') || ''; if (!href) return; let url; try { url = new URL(href, location.origin); } catch (e) { return; } // 仅处理 /t/{tid}?r={...} 这类链接 const m = url.pathname.match(/^\/t\/(\d{4,})$/); if (!m) return; const tid = m[1]; const rid = url.searchParams.get('r'); // 仅当 r 与 tid 完全一致时精简为 /t/{tid} if (rid && rid === tid) { a.setAttribute('href', `/t/${tid}`); } }); } function showQuote(html, options = {}) { const depth = $stack.children('.qp-quote').length; const $quote = $('<div class="qp-quote"></div>').css({ top: '0px', left: '0px', zIndex: 1000 + depth }); // 关联源引用元素:浮窗关闭时据此清除其“已打开”标记 if (options && options.sourceEl) $quote[0].__xdexSourceEl = options.sourceEl; const $header = $('<div class="qp-header"></div>'); const $level = $(`<span class="qp-level">第 ${depth + 1} 层</span>`); const $back = $('<button class="qp-back">返回</button>').on('click', e => { e.stopPropagation(); clearQuoteSourceMark($quote); $quote.remove(); if ($stack.children().length === 0) $overlay.fadeOut(160); }); // 跳转按钮:引用号被判为 thread(主串) 时插入到“第 N 层”左侧(不限层数) let $jump = null; const refTid = options && options.tid ? String(options.tid).trim() : ''; const ensureJumpButton = () => { if (!refTid || $jump) return; const kind = quoteAvailabilityCache && quoteAvailabilityCache[refTid] && quoteAvailabilityCache[refTid].kind; if (kind !== 'thread') return; $jump = $('<button class="qp-jump">跳转</button>').on('click', e => { e.stopPropagation(); let url = ''; try { url = getLatestThreadHistoryUrl(refTid) || `${location.origin}/t/${refTid}`; } catch (err) { url = `${location.origin}/t/${refTid}`; } window.open(url, '_blank'); }); $level.before($jump); }; // 判定为 thread 时补插:通过全局监听注册表触发,成功即注销 const onThreadAvail = (tid) => { if (String(tid) === refTid) { ensureJumpButton(); offQuoteAvailThread(refTid, onThreadAvail); } }; $header.append($level, $back); ensureJumpButton(); if (refTid) onQuoteAvailThread(refTid, onThreadAvail); $quote.append($header); const $content = stripIds($('<div></div>').html(html)); simplifyQuoteInfoIdLinks($content); // 引用浮窗内容同样应用“引用号是否当前串”标注:currentThreadId 由打开方传入 if (options && options.currentThreadId) { try { markCurrentThreadQuoteRefs($content[0], options.currentThreadId); } catch (e) {} } $quote.append($content.contents()); // 在 $quote 内添加四条边框拖拽手柄 + 四角拉伸手柄 const $edges = $( '<div class="qp-drag-edge top"></div>' + '<div class="qp-drag-edge bottom"></div>' + '<div class="qp-drag-edge left"></div>' + '<div class="qp-drag-edge right"></div>' + '<div class="qp-resize-corner nw" data-dir="nw"></div>' + '<div class="qp-resize-corner ne" data-dir="ne"></div>' + '<div class="qp-resize-corner sw" data-dir="sw"></div>' + '<div class="qp-resize-corner se" data-dir="se"></div>' ); $quote.append($edges); // 仅在标题栏 + 四边手柄上触发拖拽 enableDragForTop($quote, $quote.find('.qp-header, .qp-drag-edge')); // 四角拉伸(与回复浮窗一致) enableResizeForQuote($quote); $stack.append($quote); $overlay.fadeIn(160); if (typeof syncQuotePopupTheme === 'function') syncQuotePopupTheme(); enableHDImageAndLayoutFix($quote[0]); if (typeof extendQuote === 'function') extendQuote($quote[0]); if (typeof initExtendedContent === 'function') initExtendedContent($quote[0]); runAutoUrlLinkify($quote[0]); runLinkBlank($quote[0]); enableHDImageAndLayoutFix(); hideEmptyTitleAndEmail($quote[0]); initContent($quote[0]); try { const cfg = typeof getFilterConfig === 'function' ? getFilterConfig() : Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); markAllCookies(cfg.markedGroups || [], $quote[0]); if (cfg.enableImageHideMode) applyImageHideMode(cfg.applyImageHideMode || 'default', $quote[0]); } catch (e) {} if (options && options.fromPOImage) { setTimeout(() => { try { const imgAnchor = $quote[0].querySelector('.h-threads-img-box .h-threads-img-a'); if (!imgAnchor) return; const imgBox = imgAnchor.closest('.h-threads-img-box'); if (!imgBox || imgBox.classList.contains('h-active')) return; imgAnchor.click(); } catch (e) {} }, 0); } //autoHideRefView(); } window.__xdexOpenQuoteByTid = function(tid, options = {}) { if (!tid) return Promise.resolve(false); return fetchData(String(tid)).then(html => { // 引用浮窗“当前串”上下文:显式传入优先,否则用调用方临时写入的上下文 if (!options.currentThreadId && window.__xdexPendingQuoteCtxTid) { options.currentThreadId = window.__xdexPendingQuoteCtxTid; window.__xdexPendingQuoteCtxTid = ''; } options.tid = String(tid); showQuote(html, options); return true; }).catch(err => { console.warn('open quote by tid failed', tid, err); return false; }); }; function enableDragForTop($quote, $handles) { // 与四角缩放一致:仅在本次 mousedown 手势内挂 window 监听,mouseup 卸掉。 // 避免「后开层 off 掉全局 qpdrag → 关层后下层只剩 mousedown、拖不动」。 $handles.off('mousedown.qpdrag'); const top0 = parseInt($quote.css('top'), 10) || 0; const left0 = parseInt($quote.css('left'), 10) || 0; $quote.css({ top: top0 + 'px', left: left0 + 'px', position: 'absolute' }); $handles.on('mousedown.qpdrag', function(e){ if (e.button != null && e.button !== 0) return; // 避免点击标题栏中的交互控件(返回按钮等)触发拖拽 if ($(e.target).closest('a,button,input,textarea,select,label').length) return; e.preventDefault(); e.stopPropagation(); let dx = 0; let dy = 0; $quote.data('dragging', true); $overlay.data('isDragging', true); $quote.addClass('is-dragging'); const currentTop = parseInt($quote.css('top'), 10) || 0; const currentLeft = parseInt($quote.css('left'), 10) || 0; const stackOff = $stack.offset() || { left: 0, top: 0 }; dx = e.pageX - currentLeft - stackOff.left; dy = e.pageY - currentTop - stackOff.top; const onMove = function(ev){ if (!$quote.data('dragging')) return; if (!$quote[0] || !$quote[0].isConnected) { onUp(ev); return; } ev.preventDefault(); const off = $stack.offset() || { left: 0, top: 0 }; const stackWidth = $stack.width(); const stackHeight = $stack.height(); const quoteWidth = $quote.outerWidth(); const quoteHeight = $quote.outerHeight(); let top = ev.pageY - dy - off.top; let left = ev.pageX - dx - off.left; // 限制拖拽范围,允许向左和向上拖出一部分(至少保留 50px 可见) top = Math.max(-quoteHeight + 50, Math.min(stackHeight - 50, top)); left = Math.max(-quoteWidth + 50, Math.min(stackWidth - 50, left)); $quote.css({ top: top + 'px', left: left + 'px' }); }; const onUp = function(){ window.removeEventListener('mousemove', onMove, true); window.removeEventListener('mouseup', onUp, true); if (!$quote.data('dragging')) return; $quote.data('dragging', false); $quote.removeClass('is-dragging'); // 延迟清除,避免释放瞬间点到遮罩误关 setTimeout(() => { $overlay.data('isDragging', false); }, 100); }; window.addEventListener('mousemove', onMove, true); window.addEventListener('mouseup', onUp, true); }); } // 引用浮窗四角拉伸:对角锚定,与回复浮窗交互一致 function enableResizeForQuote($quote) { const QUOTE_MIN_W = 280; const QUOTE_MIN_H = 160; $quote.find('.qp-resize-corner').off('mousedown.qpresize').on('mousedown.qpresize', function (e) { if (e.button !== 0) return; const dir = this.getAttribute('data-dir') || ''; if (!dir) return; e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startY = e.clientY; const startRect = $quote[0].getBoundingClientRect(); const startTop = parseFloat($quote.css('top')) || 0; const startLeft = parseFloat($quote.css('left')) || 0; const startW = startRect.width; const startH = startRect.height; $quote.addClass('is-resizing'); $overlay.data('isDragging', true); // 避免缩放结束点到遮罩误关 const onMove = (ev) => { ev.preventDefault(); const dx = ev.clientX - startX; const dy = ev.clientY - startY; let w = startW; let h = startH; let top = startTop; let left = startLeft; if (dir.indexOf('e') !== -1) w = startW + dx; if (dir.indexOf('s') !== -1) h = startH + dy; if (dir.indexOf('w') !== -1) { w = startW - dx; left = startLeft + dx; } if (dir.indexOf('n') !== -1) { h = startH - dy; top = startTop + dy; } if (w < QUOTE_MIN_W) { if (dir.indexOf('w') !== -1) left = startLeft + (startW - QUOTE_MIN_W); w = QUOTE_MIN_W; } if (h < QUOTE_MIN_H) { if (dir.indexOf('n') !== -1) top = startTop + (startH - QUOTE_MIN_H); h = QUOTE_MIN_H; } const maxW = window.innerWidth; const maxH = window.innerHeight; if (w > maxW) w = maxW; if (h > maxH) h = maxH; $quote.css({ width: w + 'px', height: h + 'px', maxHeight: 'none', top: top + 'px', left: left + 'px' }); }; const onUp = () => { $quote.removeClass('is-resizing'); // 在捕获阶段拦截紧随 mouseup 的 click,防止误触关闭引用浮窗 const overlayEl = $overlay[0]; const eatClick = (ev) => { ev.stopPropagation(); overlayEl.removeEventListener('click', eatClick, true); }; overlayEl.addEventListener('click', eatClick, true); setTimeout(() => { $overlay.data('isDragging', false); overlayEl.removeEventListener('click', eatClick, true); // 兜底清理 }, 200); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }); } $(document).off('click.qp').on('click.qp', 'font[color="#789922"]', function(e){ $('#h-ref-view').hide().css('opacity', ''); // 点击时关闭原生引用框并重置透明度 e.preventDefault(); e.stopPropagation(); const tid = (this.textContent.match(/\d+/) || [])[0]; if (!tid) return; const now = Date.now(); const sourceEl = this; if (lastQuoteEl === sourceEl && (now - lastQuoteAt <= QUOTE_DOUBLE_CLICK_WINDOW || sourceEl.__xdexQuotePending || sourceEl.__xdexQuoteOpen)) { return; // 同一处引用号:请求中/浮窗已开/短时间内重复点击,忽略 } lastQuoteEl = sourceEl; lastQuoteAt = now; sourceEl.__xdexQuotePending = true; sourceEl.__xdexQuoteOpen = false; const ctxTid = getRefContextThreadId(this); window.__xdexNativeRefCtxTid = ctxTid; const refViewEl = document.getElementById('h-ref-view'); if (refViewEl) { try { markCurrentThreadQuoteRefs(refViewEl, ctxTid); } catch (e) {} } fetchData(tid).then(html => { if (sourceEl.__xdexQuotePending) sourceEl.__xdexQuotePending = false; sourceEl.__xdexQuoteOpen = true; // 与悬浮同源:点击拿到的 ref 原文同样可用于实时定论/优先判定 if (!settleQuoteRefAsEmpty(tid, html)) prioritizeQuoteProbe(tid); showQuote(html, { currentThreadId: ctxTid, tid: String(tid), sourceEl: sourceEl }); // 兑底:对最上层拓展浮窗内容再标一次,防 options 链路/后处理导致漏标 setTimeout(() => { try { const quotes = document.querySelectorAll('.qp-overlay-quote .qp-quote'); const top = quotes[quotes.length - 1]; if (top) { markCurrentThreadQuoteRefs(top, ctxTid); try { refreshQuoteAvailability(top); } catch (e) {} } console.log('[xdex] quote overlay marked', { ctx: ctxTid, ref: tid }); } catch (e) {} }, 60); }).catch(() => { // 加载失败:清除 pending,允许之后重试 sourceEl.__xdexQuotePending = false; }); }); // 原生引用浮窗标注(多路兑底):ctx 来自最近一次 hover/click 的引用号所属串; // 每次都重新 getElementById,防止 X岛 重建 #h-ref-view 元素后旧 observer/引用失效 let nativeRefMarkLast = 0; let nativeRefViewDirty = true; // 浮窗内容/上下文变化后需重新标注;稳态下跳过全量扫描 const markNativeRefViewCurrentThread = (force, ctxOverride) => { try { const refViewEl = document.getElementById('h-ref-view'); if (!refViewEl) return; const ctx = String(ctxOverride || window.__xdexNativeRefCtxTid || ''); if (!ctx) return; // 稳态短路:上下文未变且浮窗内容未变 → 不做任何查询 if (!force && !nativeRefViewDirty && refViewEl.dataset.xdexMarkedCtx === ctx) return; // 非 force 时跳过隐藏态(显示瞬间由 observer/延迟补标接管);force 用于 hover 后异步填充的补标 if (!force && refViewEl.style.display === 'none') return; markCurrentThreadQuoteRefs(refViewEl, ctx); refViewEl.dataset.xdexMarkedCtx = ctx; nativeRefViewDirty = false; } catch (e) {} }; const ensureNativeRefViewMarkObserver = () => { const refViewEl = document.getElementById('h-ref-view'); if (!refViewEl || refViewEl.__xdexRefViewMarkObserved) return; refViewEl.__xdexRefViewMarkObserved = true; const timer = { id: 0 }; const mark = () => { try { const ctx = window.__xdexNativeRefCtxTid || ''; if (!ctx) return; markCurrentThreadQuoteRefs(refViewEl, ctx); } catch (e) {} }; const observer = new MutationObserver(() => { clearTimeout(timer.id); timer.id = setTimeout(mark, 120); }); // 监听 style:X岛 将浮窗从 display:none 切换为可见的瞬间也需要标注 observer.observe(refViewEl, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['style'] }); mark(); }; // 原生 capture 阶段监听:不受 X岛 自身事件 stopPropagation / jQuery 委托时序影响,首次悬浮即能记录上下文 document.addEventListener('mouseover', function (e) { // 快速短路:绝大多数元素非引用号,一次属性比较即返回 if (!e.target || e.target.tagName !== 'FONT') return; if ((e.target.getAttribute('color') || '') !== '#789922') return; const ctx = getRefContextThreadId(e.target); if (ctx !== window.__xdexNativeRefCtxTid) nativeRefViewDirty = true; // 上下文变了需重标 window.__xdexNativeRefCtxTid = ctx; ensureNativeRefViewMarkObserver(); markNativeRefViewCurrentThread(true, ctx); // 首次悬停时序兑底:X岛 异步填充内容并显示后才能标注, // 每 300ms 重试直到浮窗内容出现标记(最多 3 次),覆盖任意填充/显示时序 let tries = 0; const retryUntilMarked = function () { tries += 1; try { const ctx2 = window.__xdexNativeRefCtxTid || ''; const refViewEl = document.getElementById('h-ref-view'); if (!ctx2 || !refViewEl) return; markCurrentThreadQuoteRefs(refViewEl, ctx2); if (!refViewEl.querySelector('font[data-xdex-cur-thread-marked-tid]') && tries < 3) { setTimeout(retryUntilMarked, 300); } } catch (e) {} }; setTimeout(retryUntilMarked, 300); }, true); $(document).on('mouseleave', 'font[color="#789922"]', function () { $('#h-ref-view').hide(); // 鼠标移开时关闭原生引用框 }); // 全局监听多个事件,确保原生引用浮窗及时隐藏 $(document).on('mousemove.refview scroll.refview wheel.refview', function(e) { const refView = document.getElementById('h-ref-view'); if (!refView || refView.style.display === 'none') return; // scroll/wheel 事件可能没有有效坐标,直接隐藏更稳妥 if (e.type === 'scroll' || e.type === 'wheel') { refView.style.display = 'none'; refView.style.opacity = ''; return; } const x = Number(e.clientX); const y = Number(e.clientY); if (!Number.isFinite(x) || !Number.isFinite(y)) { return; } if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) { return; } // 检查鼠标当前位置下的元素是否是引用号 let elementsUnderMouse = []; try { elementsUnderMouse = document.elementsFromPoint(x, y) || []; } catch (_) { return; } const quoteFont = elementsUnderMouse.find(el => el.tagName === 'FONT' && el.getAttribute('color') === '#789922'); const isOnQuote = !!quoteFont; // 如果不在引用号上,立即隐藏 if (!isOnQuote) { refView.style.display = 'none'; refView.style.opacity = ''; // 重置透明度 return; } // 即时计算触发上下文:串内页(含只看PO)直接读网址;版块/时间线用鼠标下引用号所属串 // 并对原生浮窗内容标注(兑底 mouseover/observer 链路失效的场景);稳态下内部 dirty 短路零查询 const pageTid = (typeof PageType !== 'undefined' && PageType.getThreadId) ? PageType.getThreadId(true) : ''; const liveCtxTid = pageTid || getRefContextThreadId(quoteFont); if (liveCtxTid) window.__xdexNativeRefCtxTid = liveCtxTid; const nowMarkTs = Date.now(); if (nowMarkTs - nativeRefMarkLast > 250) { nativeRefMarkLast = nowMarkTs; markNativeRefViewCurrentThread(false, liveCtxTid); } }); enableQuotePreview.__initialized = true; } function monitorRefView(){ const refView = document.getElementById('h-ref-view'); if (!refView) return; if (refView.dataset.exMonitorBound === '1') return; refView.dataset.exMonitorBound = '1'; let rafLock = 0; const observer = new MutationObserver(() => { if (refView.style.display !== 'block') return; if (rafLock) return; rafLock = requestAnimationFrame(() => { rafLock = 0; hideEmptyTitleAndEmail(refView); // 原生引用浮窗显示/内容更新时:按最近触发串上下文标注当前串引用号 try { const ctx = window.__xdexNativeRefCtxTid || ''; if (ctx && refView.style.display !== 'none') markCurrentThreadQuoteRefs(refView, ctx); } catch (e) {} }); }); observer.observe(refView, { attributes: true, attributeFilter: ['style'], childList: true, subtree: true }); } function ensureRefViewLayoutStyle() { if (document.getElementById('h-ref-view-layout-style')) return; const style = document.createElement('style'); style.id = 'h-ref-view-layout-style'; // 有图加宽改走 class 开关,避免 :has(img) 在隐藏图/残留图时误伤无图引用 style.textContent = ` #h-ref-view { box-sizing: border-box !important; max-width: calc(100vw - 2rem) !important; overflow: auto !important; overflow-wrap: break-word !important; word-break: break-word !important; } #h-ref-view.xdex-ref-view-has-image { min-width: min(52rem, calc(100vw - 2rem)) !important; } #h-ref-view.xdex-ref-view-has-image .h-threads-item-reply-main, #h-ref-view.xdex-ref-view-has-image .h-threads-content { min-width: 22rem !important; } #h-ref-view.xdex-ref-view-stack-image.xdex-ref-view-has-image { min-width: 0 !important; overflow-x: hidden !important; overflow-y: auto !important; } #h-ref-view.xdex-ref-view-stack-image.xdex-ref-view-has-image .h-threads-img-box { float: none !important; display: block !important; max-width: 100% !important; } #h-ref-view.xdex-ref-view-stack-image.xdex-ref-view-has-image .h-threads-img-a, #h-ref-view.xdex-ref-view-stack-image.xdex-ref-view-has-image img { max-width: 100% !important; } #h-ref-view.xdex-ref-view-stack-image.xdex-ref-view-has-image .h-threads-img { float: none !important; display: block !important; margin: 0 0 12px 0 !important; } #h-ref-view.xdex-ref-view-stack-image.xdex-ref-view-has-image .h-threads-item-reply-main, #h-ref-view.xdex-ref-view-stack-image.xdex-ref-view-has-image .h-threads-content { clear: both !important; display: block !important; min-width: 0 !important; width: auto !important; } #h-ref-view img { max-width: min(100%, 30rem) !important; max-height: 52vh !important; width: auto !important; height: auto !important; object-fit: contain !important; } `; document.head.appendChild(style); } // 单例 #h-ref-view 会残留上一次有图布局的 width/class,换引用时必须先复位 function resetRefViewLayoutState(refView) { if (!refView) return; refView.classList.remove('xdex-ref-view-stack-image', 'xdex-ref-view-has-image'); refView.style.width = ''; refView.style.minWidth = ''; refView.style.maxWidth = ''; } function updateRefViewImageLayout(refView, anchorEl) { if (!refView || !anchorEl) return; resetRefViewLayoutState(refView); const layoutImages = Array.from(refView.querySelectorAll('img')).filter(img => { if (img.closest('.xdex-hide-noimage')) return false; const box = img.closest('.h-threads-img-box'); if (box && getComputedStyle(box).display === 'none') return false; return getComputedStyle(img).display !== 'none'; }); // 无可见图:保持复位后的自然宽度,避免沿用上一条有图引用的加宽 if (!layoutImages.length) return; refView.classList.add('xdex-ref-view-has-image'); const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; const imageWidth = 30 * rootFontSize; const readableTextWidth = 22 * rootFontSize; const stackedReadableWidth = 38 * rootFontSize; const margin = 16; const viewportWidth = document.documentElement.clientWidth || window.innerWidth || 0; const viewportLeft = window.pageXOffset || document.documentElement.scrollLeft || 0; const viewportRight = viewportLeft + viewportWidth - margin; const anchorLeft = $(anchorEl).offset().left; const fullInlineWidth = Math.min(imageWidth + readableTextWidth, Math.max(0, viewportWidth - margin * 2)); const availableRight = viewportRight - anchorLeft; const shouldStack = availableRight < fullInlineWidth; const expectedWidth = shouldStack ? Math.min(Math.max(imageWidth, stackedReadableWidth), Math.max(0, viewportWidth - margin * 2)) : fullInlineWidth; const left = Math.max(viewportLeft + margin, Math.min(anchorLeft, viewportRight - expectedWidth)); refView.classList.toggle('xdex-ref-view-stack-image', shouldStack); if (shouldStack) { layoutImages.forEach(img => { img.removeAttribute('align'); img.removeAttribute('hspace'); }); } refView.style.left = left + 'px'; refView.style.width = expectedWidth > 0 ? expectedWidth + 'px' : ''; } function renderHiddenTextContent(root) { const $root = $(root || document); $root.find('.h-threads-content').add($root.filter('.h-threads-content')).each(function () { if (this.dataset.xdexPreviewSpoilerRendered === '1') return; const $content = $(this); let html = $content.html(); const hideenRegExp = /\[h\]([\s\S]*?)\[\/h\]/g; if (hideenRegExp.test(html)) { html = html.replace(hideenRegExp, '<span class="h-hidden-text">$1</span>'); $content.html(html); } }); } // function autoHideRefView() { // setInterval(() => { // const refView = document.getElementById('h-ref-view'); // if (!refView || getComputedStyle(refView).display === 'none') return; // // 获取当前引用框中的引用号 ID // const infoId = refView.querySelector('.h-threads-info-id'); // if (!infoId) return; // const tidMatch = infoId.textContent.match(/\d+/); // if (!tidMatch) return; // const tid = tidMatch[0]; // // 查找所有引用号元素 // const quoteFonts = document.querySelectorAll("font[color='#789922']"); // let isHovering = false; // quoteFonts.forEach(font => { // const text = font.textContent; // if (text.includes(tid) && font.matches(':hover')) { // isHovering = true; // } // }); // // 如果没有任何引用号处于 hover 状态 → 隐藏引用框 // if (!isHovering) { // refView.style.display = 'none'; // } // }, 300); // 每 300ms 检查一次 // } // —— 引用号“是否当前串”标注(tag 9 扩展):当前串判定 = 引用所在串节点 data-threads-id,串内页(含只看PO)回退 URL —— const QUOTE_REF_COLOR = '#789922'; // 与原生引用号颜色一致 function getRefContextThreadId(el) { try { if (el && el.closest) { const node = el.closest('.h-threads-item[data-threads-id]'); const tid = node && /^\d{1,}$/.test(node.getAttribute('data-threads-id') || '') ? node.getAttribute('data-threads-id').trim() : ''; if (tid && tid !== '9999999') return tid; } return (typeof PageType !== 'undefined' && PageType.getThreadId) ? PageType.getThreadId(true) || '' : ''; } catch (e) { return ''; } } function markCurrentThreadQuoteRefs(root, contextThreadId) { if (!root || !contextThreadId) return; const tids = String(contextThreadId).trim(); if (!tids) return; try { const els = Array.from(root.querySelectorAll('font[color="#789922"]')); els.forEach((el) => { if (el.dataset && el.dataset.xdexCurThreadMarkedTid === tids) return; const refNum = (String(el.textContent || '').match(/\d+/) || [])[0]; if (!refNum) return; // 不再直接设 underline:统一由 applyQuoteAvailabilityStyle 按 thread 判定设置实线/虚线 // xdexCurThreadMarkedTid 保持“已处理”防重复语义;xdexCurThreadRef 仅本串串首打,供样式判定 if (el.dataset) { el.dataset.xdexCurThreadMarkedTid = tids; if (refNum === tids) el.dataset.xdexCurThreadRef = tids; else el.dataset.xdexCurThreadRef = ''; // 已有 thread 判定时,打标记后立即刷新线型(防 mark 晚于 apply 时本串串首误显虚线) if (el.dataset.xdexQuoteAvail === 'thread') { try { applyQuoteAvailabilityStyle(el, 'thread'); } catch (e) {} } } }); // 兑底:仅处理正文区(.h-threads-content)内未渲染成 <font> 的裸引用号(如 >>No.xxx), // 消息信息区(.h-threads-info / a.h-threads-info-id,如 "No.69299379" 编号链接)一律不改 const contentRoots = []; try { if (root.matches && root.matches('.h-threads-content')) contentRoots.push(root); Array.prototype.push.apply(contentRoots, Array.from(root.querySelectorAll('.h-threads-content'))); } catch (e) {} contentRoots.forEach((contentRoot) => { const walker = document.createTreeWalker(contentRoot, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !/(>>\s*)?No\.\s*\d{4,}/.test(node.nodeValue)) return NodeFilter.FILTER_REJECT; const p = node.parentElement; if (p && p.tagName.toLowerCase() === 'font' && (p.getAttribute('color') || '').toLowerCase() === '#789922') return NodeFilter.FILTER_REJECT; if (p && p.closest && p.closest('a.h-threads-info-id, .h-threads-info')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); const bareNodes = []; let bn; while ((bn = walker.nextNode())) bareNodes.push(bn); bareNodes.forEach((textNode) => { const text = textNode.nodeValue; const frag = document.createDocumentFragment(); let cursor = 0; const re = /(>>\s*)?No\.\s*(\d{4,})/g; let m; while ((m = re.exec(text))) { const start = m.index, end = re.lastIndex; const refNum = m[2]; if (start > cursor) frag.appendChild(document.createTextNode(text.slice(cursor, start))); const font = document.createElement('font'); font.setAttribute('color', '#789922'); font.textContent = text.slice(start, end); // 不再直接设 underline:统一由 applyQuoteAvailabilityStyle 按 thread 判定设置实线/虚线 if (font.dataset) { font.dataset.xdexCurThreadMarkedTid = tids; if (refNum === tids) font.dataset.xdexCurThreadRef = tids; else font.dataset.xdexCurThreadRef = ''; if (font.dataset.xdexQuoteAvail === 'thread') { try { applyQuoteAvailabilityStyle(font, 'thread'); } catch (e) {} } } frag.appendChild(font); cursor = end; } if (cursor < text.length) frag.appendChild(document.createTextNode(text.slice(cursor))); textNode.replaceWith(frag); }); }); } catch (e) {} } window.__xdexQuoteRefMark = { getContextThreadId: getRefContextThreadId, markCurrentThreadQuoteRefs: markCurrentThreadQuoteRefs }; // —— 引用串可用性检测:仅保留当前页面会话结果,不写入 GM 存储 —— // 「已删/不存在」的标准响应不是 DOCTYPE 裸页,而是服务端照常渲染的 h-threads-item 空字段骨架: // data-threads-id 为空、No. 锚文本无编号、ID: 无饼干、正文为空。二者含义不同,不可混用。 function isQuoteRefDeletedSkeleton(html) { if (typeof html !== 'string' || !html) return false; // 编号锚为空:活帖浮窗必然带 No.<数字>,此处只剩 “No.” const emptyNoAnchor = /<a\b[^>]*h-threads-info-id[^>]*>\s*No\.\s*<\/a>/i.test(html); // 容器存在但串号为空 const emptyThreadsId = /data-threads-id\s*=\s*["']\s*["']/i.test(html); return emptyNoAnchor && emptyThreadsId; } function parseQuoteResponseForAvailability(html, tid) { if (html == null || String(html).trim() === '') return { kind: 'empty' }; const text = String(html).trim(); let parsed = null; try { parsed = JSON.parse(text); } catch (e) {} if (parsed && typeof parsed === 'object') { if (parsed.success === false || parsed.error) return { kind: 'reply' }; if (parsed.id != null || parsed.content != null || Array.isArray(parsed.Replies)) return { kind: 'thread' }; } if (/<!DOCTYPE html>\s*<html[^>]*>\s*<head[\s>]/i.test(text) && !/<(?:body|div|article|main)\b/i.test(text)) return { kind: 'empty' }; if (tid && (text.includes('id="' + tid + '"') || text.includes("id='" + tid + "'") || text.includes('data-threads-id="' + tid + '"') || text.includes("data-threads-id='" + tid + "'"))) return { kind: 'thread' }; // 【已停用·注释保留】类名 token 分支无法区分“主串存在”与“已删”:服务端对不存在的编号照样渲染 // <div class="h-threads-item">,只是字段全空。实测删串响应即含该容器,无论宽分支还是收紧后的 // token 分支都会误判为 thread(进而误插跳转按钮)。ref 原文只能用来判存在性,不能判主串。 // if (/(?:class|id)=["'](?:[^"']*\s)?h-threads-item(?:-index)?(?:\s[^"']*)?["']/i.test(text)) return { kind: 'thread' }; // 已删骨架优先于兜底的 reply 结论 if (isQuoteRefDeletedSkeleton(text)) return { kind: 'empty' }; return { kind: 'reply' }; } function createQuoteAvailabilityQueue(options = {}) { const pending = new Set(); const inFlight = new Set(); const attempts = new Map(); const MAX_ATTEMPTS = 3; const fetchFn = options.fetchFn || (() => Promise.reject(new Error('quote availability fetch unavailable'))); const cacheGet = options.cacheGet || (() => null); const cacheSet = options.cacheSet || (() => {}); const onResult = options.onResult || (() => {}); let timer = null; let activeWorkers = 0; // 受限并发:一次刷新可能有数百个编号,全量并发会被 CDN 限流(限流错误即 unknown 的来源) // 并发数是实际的限流手段;GAP_MS 为单个 worker 相邻两次请求间的最小间隔,默认关闭,需要时由调用方显式传 gapMs // 判定必须用 Number.isFinite:Number(undefined) 是 NaN,而 `NaN != null` 恒为 true,会让默认分支变成死代码 const CONCURRENCY = Math.max(1, Number(options.concurrency) || 2); const GAP_MS = Number.isFinite(Number(options.gapMs)) ? Math.max(0, Number(options.gapMs)) : 0; const delay = options.noDelay ? 0 : (Number(options.delay) || 120); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const RETRY_BASE_MS = 1500; const retryLater = (tid) => { const n = (attempts.get(tid) || 0) + 1; attempts.set(tid, n); if (n >= MAX_ATTEMPTS) return false; setTimeout(() => { if (!cacheGet(tid) && !inFlight.has(tid) && !pending.has(tid)) { pending.add(tid); schedule(); } }, RETRY_BASE_MS * n); return true; }; const drainOne = async (tid) => { inFlight.add(tid); try { const cached = cacheGet(tid); if (cached && cached.kind) { onResult(tid, cached.kind); return; } const response = await fetchFn(tid); const parsed = parseQuoteResponseForAvailability(response && response.html, tid); const result = { kind: parsed.kind, t: Date.now() }; // 只缓存明确判定(thread/reply/empty);失败不缓存,保留后续重试资格 cacheSet(tid, result); attempts.delete(tid); onResult(tid, result.kind); } catch (e) { // 请求失败(限流/超时/断网):不入缓存,退避后自动重试,重试耗尽则本轮放弃 const willRetry = retryLater(tid); onResult(tid, 'unknown'); if (!willRetry) console.warn('[xdex-availability] 放弃重试', { id: tid }); } finally { inFlight.delete(tid); } }; // 常驻 worker:谁空了谁取下一个编号,消除“凑满一批 → 等最慢的一个 → 整批空等”的头部阻塞 // 峰值在途请求数仍严格等于 CONCURRENCY,不增加任何 CDN 压力 const startWorker = () => { activeWorkers++; const run = async () => { try { while (pending.size) { const id = pending.values().next().value; pending.delete(id); await drainOne(id); if (GAP_MS && pending.size) await sleep(GAP_MS); } } finally { activeWorkers--; } // 退出与入队存在竞态:仍有待办则补位,避免任务搁置到下一次 schedule if (pending.size && activeWorkers < CONCURRENCY) startWorker(); }; run(); }; const pump = () => { while (activeWorkers < CONCURRENCY && pending.size) startWorker(); }; const schedule = () => { if (timer !== null) return; if (options.noDelay) { pump(); return; } timer = setTimeout(() => { timer = null; pump(); }, delay); }; return { // opts.priority:用户正在查看的编号提到队首,不排在数百个后台编号之后 enqueue(tid, opts) { const id = String(tid || '').trim(); if (!/^\d\d\d\d\d\d\d\d$/.test(id) || cacheGet(id) || inFlight.has(id)) return false; if ((attempts.get(id) || 0) >= MAX_ATTEMPTS) return false; const priority = !!(opts && opts.priority); if (pending.has(id)) { if (!priority) return false; pending.delete(id); } if (priority) { const rest = Array.from(pending); pending.clear(); pending.add(id); rest.forEach((x) => pending.add(x)); } else { pending.add(id); } schedule(); return true; }, // 已凭悬浮/点击的 ref 原文实时定论的编号:撤掉尚未发出的探测,省掉 api/ref+api/thread drop(tid) { const id = String(tid || '').trim(); const had = pending.delete(id); if (had) attempts.delete(id); return had; }, flushNow() { if (timer !== null) { clearTimeout(timer); timer = null; } pump(); return new Promise((resolve) => { const wait = () => (pending.size || inFlight.size || activeWorkers) ? setTimeout(wait, 0) : resolve(); wait(); }); }, pendingSize() { return pending.size + inFlight.size; } }; } function applyQuoteAvailabilityStyle(el, kind) { if (!el || !el.style) return; const value = kind === 'thread' || kind === 'empty' || kind === 'reply' || kind === 'unknown' ? kind : 'unknown'; if (el.dataset) el.dataset.xdexQuoteAvail = value; // 统一不加粗:主串用横线区分;本串串首实线,其他主串虚线 el.style.fontWeight = ''; el.style.textDecoration = ''; el.style.textDecorationThickness = ''; if (value === 'thread') { const isCurrentThread = !!(el.dataset && el.dataset.xdexCurThreadRef); el.style.textDecoration = isCurrentThread ? 'underline' : 'underline dashed'; el.style.textDecorationThickness = '2px'; } el.style.opacity = value === 'empty' ? '0.5' : ''; } // 提取元素文本中的引用编号:仅标准(>>No. / No.)与拓展(独立 8 位数字 / >>8位)格式; // 不含编号的绿色文本(如 ">>如我所见")返回 null,视为非引用,不判定、不改样式 function getQuoteRefIdFromText(text) { const m = String(text || '').match(/(?:>>)?No\.\s?(\d{8})\b|(?:>>)?(?<!\d)(\d{8})(?!\d)/); return m ? (m[1] || m[2]) : null; } function probeQuoteAvailability(el, context = {}) { const text = String(el && el.textContent || ''); // 仅当文本是标准/拓展引用时才判定;不含引用编号的绿色 font(如 ">>如我所见")不是引用,直接跳过 const tid = getQuoteRefIdFromText(text); if (!tid) return { kind: 'skip', queued: false }; const state = context.state || {}; if (state.extendQuoteAvailabilityDetection === false || state.extendQuote === false) return { kind: 'unknown', queued: false }; const cached = context.cache && context.cache[tid]; const kind = cached && cached.kind ? cached.kind : 'unknown'; applyQuoteAvailabilityStyle(el, kind); if (cached || !context.queueSet) return { kind, queued: false }; context.queueSet(tid); return { kind, queued: true }; } const quoteAvailabilityCache = Object.create(null); let quoteAvailabilityQueue = null; // 跳转按钮补插监听:key = 引用串号,value = Set<回调>(浮窗打开后注册,判定为 thread 时触发) const quoteJumpListeners = new Map(); function onQuoteAvailThread(tid, fn) { const id = String(tid || ''); if (!id || typeof fn !== 'function') return; if (!quoteJumpListeners.has(id)) quoteJumpListeners.set(id, new Set()); quoteJumpListeners.get(id).add(fn); } function offQuoteAvailThread(tid, fn) { const id = String(tid || ''); const set = quoteJumpListeners.get(id); if (set) { set.delete(fn); if (!set.size) quoteJumpListeners.delete(id); } } let xdexAvailSummary = { thread: 0, reply: 0, empty: 0, unknown: 0 }; let xdexAvailSummaryTimer = null; function xdexAvailLogSummary() { const s = xdexAvailSummary; const total = s.thread + s.reply + s.empty + s.unknown; if (total <= 0) return; console.log('[xdex-availability] 判定汇总', { thread: s.thread, reply: s.reply, empty: s.empty, unknown: s.unknown }); xdexAvailSummary = { thread: 0, reply: 0, empty: 0, unknown: 0 }; } function getQuoteAvailabilityState() { try { return Object.assign({}, SettingPanel.defaults, SettingPanel.state || {}, GM_getValue(SettingPanel.key, {})); } catch (e) { return { extendQuote: true, extendQuoteAvailabilityDetection: true }; } } function fetchQuoteAvailability(tid) { const id = encodeURIComponent(tid); const refUrl = `https://api.nmb.best/api/ref?id=${id}`; const threadUrl = `https://api.nmb.best/api/thread?id=${id}&page=1`; const headers = typeof getPostHistoryApiCookieHeaders === 'function' ? getPostHistoryApiCookieHeaders() : null; const readResponse = (resp) => { const raw = resp && (resp.response || resp.responseText || ''); if (raw && typeof raw === 'object') return raw; try { return JSON.parse(String(raw || '')); } catch (e) { return null; } }; return gmRequest(refUrl, 'text', headers).then((refResp) => { const ref = readResponse(refResp); // ref 存在且非业务失败 → 目标编号确实存在;是否主串由 thread 判定 if (!ref || ref.success === false || ref.error) return { html: '', httpStatus: 200 }; return gmRequest(threadUrl, 'text', headers).then((threadResp) => { const thread = readResponse(threadResp); if (thread && thread.success === false) return { html: JSON.stringify({ success: false, error: thread.error || 'reply' }), httpStatus: 200 }; return { html: JSON.stringify(thread || {}), httpStatus: 200 }; }).catch((err) => { // thread 明确 404 → 该编号不是主串,是回复 if (err && /HTTP 404\b/.test(String(err.message || err))) { return { html: JSON.stringify({ success: false, error: 'reply' }), httpStatus: 404 }; } throw err; }); }); } // 判定结果统一出口:样式回填、跳转按钮补插、汇总日志。队列回调与悬浮/点击实时回填共用 function applyQuoteAvailabilityResult(tid, kind) { // 检测关闭时不得改样式:多标签页远程关闭后,已入队探测的残余回调不得再回填全文档(L2 缺口) if (!isQuoteAvailabilityEnabled()) return; // 仅对真正包含该引用编号的元素应用样式;不含编号的绿色文本不改动 document.querySelectorAll('font[color="#789922"]').forEach((el) => { if (getQuoteRefIdFromText(el.textContent) === tid) applyQuoteAvailabilityStyle(el, kind); }); // 判定为 thread(主串) 时,通知所有打开该引用浮窗的跳转按钮补插 if (kind === 'thread' && quoteJumpListeners && quoteJumpListeners.size) { const set = quoteJumpListeners.get(String(tid)); if (set && set.size) { [...set].forEach((fn) => { try { fn(String(tid)); } catch (e) {} }); } } // 批量汇总:一次扫掠只打一条汇总日志,不逐 id 输出(发送消息后整片新引用号会一次性判定,逐行会刷屏) if (xdexAvailSummary[kind] != null) xdexAvailSummary[kind] += 1; if (xdexAvailSummaryTimer) return; xdexAvailSummaryTimer = setTimeout(() => { xdexAvailSummaryTimer = null; xdexAvailLogSummary(); }, 300); } function getQuoteAvailabilityQueue() { if (quoteAvailabilityQueue) return quoteAvailabilityQueue; quoteAvailabilityQueue = createQuoteAvailabilityQueue({ fetchFn: fetchQuoteAvailability, cacheGet: (tid) => quoteAvailabilityCache[tid], cacheSet: (tid, entry) => { quoteAvailabilityCache[tid] = entry; }, onResult: applyQuoteAvailabilityResult }); return quoteAvailabilityQueue; } // 悬浮/点击路径自己会打 /Home/Forum/ref,命中“已删骨架”即得到实时可用性结论, // 据此直接定论并撤掉尚未发出的探测,省掉后台的 api/ref + api/thread 两次重复请求 // 【已停用·注释保留】DOCTYPE 裸页对应“请求没返回可渲染内容”(登录跳转/错误页/被拦),与“已删”是两件事, // 不作为删串判据;悬浮路径原有的“不渲染浮窗”早退仍按该标记执行。 const QUOTE_REF_EMPTY_MARK = '<!DOCTYPE html><html><head>'; // 悬浮/点击回填与优先探测同样受开关约束:检测关闭时不得建队、不得改样式 function isQuoteAvailabilityEnabled() { const state = getQuoteAvailabilityState(); return state.extendQuoteAvailabilityDetection !== false && state.extendQuote !== false; } function settleQuoteRefAsEmpty(tid, html) { const id = String(tid || '').trim(); if (!/^\d{8}$/.test(id)) return false; // 【已停用·注释保留】旧判据把 DOCTYPE 裸页当“已删”,实际打不到标准删串响应(它是空字段骨架,无 DOCTYPE) // if (typeof html !== 'string' || html.indexOf(QUOTE_REF_EMPTY_MARK) < 0) return false; if (!isQuoteRefDeletedSkeleton(html)) return false; if (!isQuoteAvailabilityEnabled()) return false; if (!quoteAvailabilityCache[id] || !quoteAvailabilityCache[id].kind) { quoteAvailabilityCache[id] = { kind: 'empty', t: Date.now() }; } if (quoteAvailabilityQueue) quoteAvailabilityQueue.drop(id); applyQuoteAvailabilityResult(id, 'empty'); return true; } // 用户正在查看的编号提到队首,不必排在数百个后台编号之后 function prioritizeQuoteProbe(tid) { const id = String(tid || '').trim(); if (!/^\d{8}$/.test(id)) return; if (!isQuoteAvailabilityEnabled()) return; if (quoteAvailabilityCache[id] && quoteAvailabilityCache[id].kind) return; getQuoteAvailabilityQueue().enqueue(id, { priority: true }); } // 视口门控:只对进入视口(含预读边距)的引用号入队,使探测进度跟随阅读位置, // 而不是页面一打开就按 DOM 顺序排队数百个编号、用户读到哪都还没轮到哪 const QUOTE_PROBE_ROOT_MARGIN = '200px'; let quoteProbeObserver = null; function observeQuoteProbe(el, tid) { const id = String(tid || '').trim(); if (!/^\d{8}$/.test(id)) return; if (typeof IntersectionObserver !== 'function') { getQuoteAvailabilityQueue().enqueue(id); return; } if (!quoteProbeObserver) { quoteProbeObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const waiting = entry.target.__xdexQuoteProbeTid; delete entry.target.__xdexQuoteProbeTid; quoteProbeObserver.unobserve(entry.target); if (waiting && isQuoteAvailabilityEnabled()) getQuoteAvailabilityQueue().enqueue(waiting); }); }, { rootMargin: QUOTE_PROBE_ROOT_MARGIN }); } if (el.__xdexQuoteProbeTid === id) return; // 已在观察同一编号,避免重复 observe el.__xdexQuoteProbeTid = id; quoteProbeObserver.observe(el); } function refreshQuoteAvailability(root = document) { const state = getQuoteAvailabilityState(); if (state.extendQuoteAvailabilityDetection === false || state.extendQuote === false) return; getQuoteAvailabilityQueue(); const fonts = []; if (root && root.matches && root.matches('font[color="#789922"]')) fonts.push(root); if (root && root.querySelectorAll) fonts.push(...root.querySelectorAll('font[color="#789922"]')); fonts.forEach((el) => probeQuoteAvailability(el, { state, cache: quoteAvailabilityCache, queueSet: (tid) => observeQuoteProbe(el, tid) })); } window.__xdexQuoteAvailability = { refresh: refreshQuoteAvailability, probe: probeQuoteAvailability }; //引用格式拓展 function extendQuote(root = document) { return startupPerfDebug.measure('extendQuote', () => { const ROOT_SELECTOR = '.h-threads-content, .h-post-form-input, .xdex-post-history-thread'; const QUOTE_COLOR = '#789922'; // 在容器内遍历纯文本节点,避免破坏现有标签 const quoteRoots = root.matches?.(ROOT_SELECTOR) ? [root] : Array.from(root.querySelectorAll(ROOT_SELECTOR)); quoteRoots.forEach(root => { const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode(node) { // 没数字直接跳过 if (!node.nodeValue || !/\d/.test(node.nodeValue)) { return NodeFilter.FILTER_REJECT; } // 已经在 quote 的 <font color="#789922"> 内,跳过 const p = node.parentElement; if (p && p.tagName.toLowerCase() === 'font' && (p.getAttribute('color') || '').toLowerCase() === QUOTE_COLOR) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } } ); const textNodes = []; let n; while ((n = walker.nextNode())) textNodes.push(n); textNodes.forEach(processTextNode); // 原生标准引用号(>>No.12345678 已由原站渲染为 <font color="#789922">)会被 walker 跳过, // 这里统一补标:引用号 == 当前串号 → 同色下划线 try { markCurrentThreadQuoteRefs(root, getRefContextThreadId(root)); } catch (e) {} try { refreshQuoteAvailability(root); } catch (e) {} }); function processTextNode(textNode) { const text = textNode.nodeValue; // 两类模式:No.12345678 与 单独 8 位数字 const patterns = [ { name: 'no', regex: /(?:>>)?No\.(\d{8})\b/g }, // 匹配 No.12345678 或 >>No.12345678 { name: 'num', regex: /(?:>>)?(?<!\d)(\d{8})(?!\d)/g } // 匹配 12345678 或 >>12345678 ]; const frag = document.createDocumentFragment(); let cursor = 0; let changed = false; while (true) { const next = findNextMatch(text, cursor, patterns); if (!next) break; const { start, end } = next; // 若匹配前紧挨着 ">>",说明是标准引用的一部分,跳过这次,继续后移 // if (start >= 2 && text[start - 2] === '>' && text[start - 1] === '>') { // // 只推进一位,继续找后面的匹配,避免卡住 // cursor = start + 1; // continue; // } // 追加匹配前的原始文本 if (start > cursor) { frag.appendChild(document.createTextNode(text.slice(cursor, start))); } // 用与标准引用一致的 <font color="#789922"> 包裹,但不添加 ">>" const font = document.createElement('font'); font.setAttribute('color', QUOTE_COLOR); // 直接保留原始匹配文本(可能是 "No.12345678" 或 "12345678") font.textContent = text.slice(start, end); // 标识引用号是否为当前串串号:是则在引用号下方添加同色横线(underline 贴近文字) // 回应模式行(实际容器 .h-post-form-grid / .js-reply-mode-row)内的 No.xxxx 不添加横线 const refNum = (font.textContent.match(/\d+/) || [])[0]; const modeTextEl = textNode.parentElement && textNode.parentElement.closest ? textNode.parentElement.closest('.h-post-form-grid, .js-reply-mode-text, .js-reply-mode-row') : null; if (refNum && !modeTextEl && refNum === getRefContextThreadId(textNode.parentElement)) { // 不再直接设 underline:统一由 applyQuoteAvailabilityStyle 按 thread 判定设置实线/虚线 if (font.dataset) { font.dataset.xdexCurThreadMarkedTid = refNum; font.dataset.xdexCurThreadRef = refNum; if (font.dataset.xdexQuoteAvail === 'thread') { try { applyQuoteAvailabilityStyle(font, 'thread'); } catch (e) {} } } } frag.appendChild(font); cursor = end; changed = true; } if (!changed) return; // 末尾剩余文本 if (cursor < text.length) { frag.appendChild(document.createTextNode(text.slice(cursor))); } textNode.replaceWith(frag); } // 在多正则间找到下一处最早的匹配 function findNextMatch(text, fromIndex, patterns) { let best = null; for (const p of patterns) { p.regex.lastIndex = fromIndex; const m = p.regex.exec(text); if (m) { const start = m.index; const end = start + m[0].length; if (!best || start < best.start) { best = { start, end }; } } } return best; } try { refreshQuoteAvailability(root); } catch (e) {} }, () => startupPerfDebug.summarizeRoot(root)); } function initExtendedContent(root) { return startupPerfDebug.measure('initExtendedContent', () => { const $root = $(root || document); ensureRefViewLayoutStyle(); renderHiddenTextContent(root); // 先处理隐藏文本,再挂处理器,避免 $content.html() 重写破坏处理器 try { injectSubscriptionExButton(root || document); } catch (e) {} // —— 在捕获阶段接管原生引用浮窗,避免原站先显示未处理内容 —— const _xvFindResult = $root.find("font[color='#789922']"); // console.log('[xv-ref-find] $root.find result:', _xvFindResult.length, 'root:', $root[0]?.tagName, $root[0]?.className); _xvFindResult.add($root.filter("font[color='#789922']")) .filter(function () { return /(No\.\d{8}|\d{8})/.test($(this).text()); }) .off('mouseenter.ext') .each(function () { const quoteEl = this; if (quoteEl.__xdexRefHoverHandler) { quoteEl.removeEventListener('mouseover', quoteEl.__xdexRefHoverHandler, true); quoteEl.removeEventListener('mouseenter', quoteEl.__xdexRefHoverHandler, true); } // console.log('[xv-ref-attach] attaching handler to:', quoteEl.textContent, 'parent:', quoteEl.parentElement.tagName); quoteEl.__xdexRefHoverHandler = function (event) { if (event.type === 'mouseover' && event.relatedTarget && quoteEl.contains(event.relatedTarget)) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); const match = /\d+/.exec($(quoteEl).text()); if (!match) return; const tid = match[0]; const seq = (window.__xdexRefViewRequestSeq || 0) + 1; window.__xdexRefViewRequestSeq = seq; const $rv = $("#h-ref-view").off().stop(true, true).hide().css('visibility', 'hidden'); // 换引用前先清掉上一次有图布局残留的 width/class,避免空/无图内容继承超长宽度 if (typeof resetRefViewLayoutState === 'function') resetRefViewLayoutState($rv[0]); // console.log('[xv-ref] handler fired, tid=' + tid + ', quoteText=' + $(quoteEl).text()); // console.log('[xv-ref] #h-ref-view exists:', $('#h-ref-view').length, 'display:', $('#h-ref-view').css('display')); $.get('/Home/Forum/ref?id=' + tid) .done(function (data) { if (seq !== window.__xdexRefViewRequestSeq) return; // 空壳即实时可用性结论:直接定论并撤掉后台探测;否则把该编号提到队首优先判定 if (!settleQuoteRefAsEmpty(tid, data)) prioritizeQuoteProbe(tid); if (data.indexOf('<!DOCTYPE html><html><head>') >= 0) return; $rv.html(data).css({ top: $(quoteEl).offset().top, left: $(quoteEl).offset().left }); // html 替换后再次复位,防止 jQuery/原站样式把旧 inline width 带回来 if (typeof resetRefViewLayoutState === 'function') resetRefViewLayoutState($rv[0]); try { const refEl = $rv[0]; hideEmptyTitleAndEmail(refEl); renderHiddenTextContent(refEl); if (typeof extendQuote === 'function') extendQuote(refEl); if (typeof initExtendedContent === 'function') initExtendedContent(refEl); try { refreshQuoteAvailability(refEl); } catch (e) {} const _cfg = typeof getFilterConfig === 'function' ? getFilterConfig() : Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); markAllCookies(_cfg.markedGroups || [], refEl); if (_cfg.enableImageHideMode) applyImageHideMode(_cfg.applyImageHideMode || 'default', refEl); if (_cfg.enableAutoUrlLinkify) runAutoUrlLinkify(refEl); updateRefViewImageLayout(refEl, quoteEl); } catch (e) {} // console.log('[xv-ref] AJAX done, showing popup, offset:', $(quoteEl).offset()); $rv.css('visibility', '').show(); // console.log('[xv-ref] popup shown, display:', $rv.css('display'), 'visibility:', $rv.css('visibility')); }); }; quoteEl.addEventListener('mouseover', quoteEl.__xdexRefHoverHandler, true); quoteEl.addEventListener('mouseenter', quoteEl.__xdexRefHoverHandler, true); }); // —— 新增:处理 [h]...[/h] 隐藏文本 —— try { refreshQuoteAvailability(root || document); } catch (e) {} }, () => startupPerfDebug.summarizeRoot(root || document)); } /* -------------------------------------------------- * tag 10. 创建拓展坞+reply按钮呼出回复悬浮窗 * -------------------------------------------------- */ function isBoardThreadListPage() { // 版块页/时间线页:顶层串列表(串内页无串列表,保持三按钮) return PageType.isBoardPage() || PageType.isTimelineAnyPage(); } function scrollPageToY(targetY, behavior) { try { window.scrollTo({ top: targetY, left: 0, behavior: behavior || 'smooth' }); } catch (e) { try { window.scrollTo(0, targetY); } catch (e2) { document.documentElement.scrollTop = targetY; if (document.body) document.body.scrollTop = targetY; } } } function isThreadItemVisible(el) { if (!el || !el.isConnected) return false; try { const cs = window.getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; } catch (e) { return true; } } function getBoardThreadItems() { // 只收集可见顶层串:过滤 display:none 的屏蔽/隐藏项,避免其 rect.top=0 干扰基准判定 return Array.from(document.querySelectorAll('.h-threads-list > .h-threads-item[data-threads-id]')) .filter(isThreadItemVisible); } function isSeamlessPagingEnabled() { try { if (SettingPanel && SettingPanel.state) return !!SettingPanel.state.enableSeamlessPaging; const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); return !!cfg.enableSeamlessPaging; } catch (e) { return false; } } function triggerNextPageLoad() { logRightSidebarDocker('thread-nav-nextpage', { seamlessEnabled: isSeamlessPagingEnabled() }); // 无缝翻页启用:与触底/手动翻页一致,调用 loadNext(自动/手动模式共用) if (isSeamlessPagingEnabled() && window.SeamlessPaging && typeof window.SeamlessPaging.loadNext === 'function') { try { window.SeamlessPaging.loadNext(); return true; } catch (e) { console.warn('[rightSidebarDocker] loadNext 调用失败', e); } } // 非无缝:跳转站点分页「下一页」链接 try { const pager = document.querySelector('ul.uk-pagination.uk-pagination-left.h-pagination'); const nextLi = pager && Array.from(pager.querySelectorAll('li')).find((li) => { const t = (li.textContent || '').trim(); return /下一页|下页|Next|›|»|→/i.test(t) && !li.classList.contains('uk-disabled'); }); const link = nextLi && nextLi.querySelector('a[href]'); if (link && link.href) { let url = null; try { url = new URL(link.href, location.href); } catch (e) {} // 仅当解析出的地址与当前页不同才跳转,避免异常 href(如首页/空链)造成误跳 if (url && url.href !== location.href) { logRightSidebarDocker('thread-nav-nextpage-native', { href: url.href }); location.href = url.href; return true; } } } catch (e) {} return false; } function getWebdavPageScrollY() { return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; } let navLastAlign = null; // {tid, y, ts}:最近一次“对齐当前串顶”的目标,用于抗布局漂移判定 function scrollToAdjacentThread(direction) { const items = getBoardThreadItems(); if (!items.length) { logRightSidebarDocker('thread-nav-skip', { reason: 'no-thread-items' }); if (typeof toast === 'function') toast('当前页面没有串'); return; } // 即时判定(每次点击实时计算,无缓存、不依赖上次点击状态): // 当前串 = 视口顶部线正压着的串(top<=0 && bottom>0 的第一个); // 页面顶部未滚动 → 第一个 top>=0 的串;全部滚出视口上方 → 最后一个 let base = -1; for (let i = 0; i < items.length; i++) { const r = items[i].getBoundingClientRect(); if (r.top <= 0 && r.bottom > 0) { base = i; break; } if (r.top >= 0) { base = i; break; } } if (base < 0) base = items.length - 1; const curTop = items[base].getBoundingClientRect().top; // 上一串:当前串顶部已被滚出视口上方(顶部线压在其中间)→ 先滚回当前串顶部 // 抗漂移判定:若刚对齐过同一个串且页面未被手动滚动(无缝翻页/懒加载造成的布局漂移会让 // curTop 持续为负,此时按像素阈值判断会永远“未对齐”),直接视为已对齐并跳前串 if (direction < 0 && curTop < 0) { const now = Date.now(); const scrollY = getWebdavPageScrollY(); const tid = items[base].getAttribute('data-threads-id'); if (navLastAlign && navLastAlign.tid === tid && now - navLastAlign.ts < 10000 && Math.abs(scrollY - navLastAlign.y) < 3) { navLastAlign = null; // 已对齐(即使布局漂移后 curTop 仍为负):走下方跳转分支 } else { const y = curTop + scrollY; logRightSidebarDocker('thread-nav-align-current', { base, tid, y }); navLastAlign = { tid, y, ts: now }; scrollPageToY(y, 'auto'); return; } } navLastAlign = null; const target = base + direction; if (target < 0 || target >= items.length) { logRightSidebarDocker('thread-nav-boundary', { direction, base, target, total: items.length }); // 向下到末尾:主动加载下一页(无缝/手动按设置,类似触底翻页);向上则提示 if (direction > 0 && triggerNextPageLoad()) { return; } if (typeof toast === 'function') toast(direction > 0 ? '已经是最后一个串' : '已经是第一个串'); return; } const el = items[target]; const y = el.getBoundingClientRect().top + getWebdavPageScrollY(); logRightSidebarDocker('thread-nav', { direction, base, target, tid: el.getAttribute('data-threads-id'), y, scrollTop: getWebdavPageScrollY() }); scrollPageToY(y); } function buildRightSidebarDockerHtml() { const withThreadNav = isBoardThreadListPage(); return ` <div class="hld__docker"> <div class="hld__docker-sidebar"> <svg viewBox="0 0 1024 1024" width="64" height="64"> <path d="M518.3 824.05c-7.88 0-15.76-2.97-21.69-9L215.25 533.65c-5.73-5.73-9-13.61-9-21.69s3.27-15.96 9-21.69l281.4-281.4c11.97-11.97 31.41-11.97 43.39 0s11.97 31.41 0 43.39L280.33 511.95l259.71 259.71c11.97 11.97 11.97 31.41 0 43.39-5.94 6.04-13.72 9-21.69 9z" fill="#888"/> <path d="M787.16 772.89c-7.88 0-15.76-2.97-21.69-9L535.23 533.65c-11.97-11.97-11.97-31.41 0-43.39l230.24-230.24c11.97-11.97 31.41-11.97 43.39 0s11.97 31.41 0 43.39L600.31 511.95l208.55 208.55c11.97 11.97 11.97 31.41 0 43.39-5.94 6.04-13.72 9-21.69 9z" fill="#888"/> </svg> </div> <div class="hld__docker-btns"> <div data-type="TOP" title="回到顶部"><svg viewBox="0 0 24 24" width="22" height="22"><path d="M12 8 L18 15 H6 Z"/><path d="M12 3 L16 7 H8 Z"/></svg></div> ${withThreadNav ? '<div data-type="PREV-THREAD" title="上一个串"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 6 L19 17 H5 Z"/></svg></div>' : ''} <div data-type="REPLY"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path style="fill:none" d="M19.9 4.8V14.2a1.5 1.5 0 0 1-1.5 1.5H4.1"/><path style="fill:none" d="M8.8 19.2L4.1 15.7l4.7-3.5"/></svg></div> ${withThreadNav ? '<div data-type="NEXT-THREAD" title="下一个串"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 18 L5 7 H19 Z"/></svg></div>' : ''} <div data-type="BOTTOM" title="到底部"><svg viewBox="0 0 24 24" width="22" height="22"><path d="M12 16 L6 9 H18 Z"/><path d="M12 21 L8 17 H16 Z"/></svg></div> </div> </div> `; } function ensureRightSidebarShellStyle() { // 可重复刷新文本:热更新后避免旧 transition:all 残留 let style = document.getElementById('xdex-right-sidebar-shell-style'); if (!style) { style = document.createElement('style'); style.id = 'xdex-right-sidebar-shell-style'; (document.head || document.documentElement).appendChild(style); } style.textContent = ` #h-tool { display: none !important; } /* 只过渡几何属性,缩短时长,减轻首屏卡顿体感 */ .hld__docker { position: fixed; height: 80px; width: 30px; bottom: 180px; right: 0; transition: width .12s ease, height .12s ease, bottom .12s ease; z-index: 9998; } .hld__docker:hover, .hld__docker.is-hover { width: 150px; height: 300px; bottom: 75px; } .hld__docker:has(.hld__docker-sidebar:hover) { width: 150px; height: 300px; bottom: 75px; } /* 进页约 1.5s 内关闭尺寸过渡,悬停几乎即时展开,躲开 ready/batch 长任务掉帧 */ .hld__docker.xdex-docker-boot, .hld__docker.xdex-docker-boot:hover, .hld__docker.xdex-docker-boot.is-hover, .hld__docker.xdex-docker-boot:has(.hld__docker-sidebar:hover) { transition: none; } .hld__docker-sidebar { background: #fff; position: fixed; height: 50px; width: 20px; bottom: 195px; right: 0; display: flex; justify-content: center; align-items: center; border: 1px solid #CCC; box-shadow: 0 0 1px #333; border-right: none; border-radius: 5px 0 0 5px; } .hld__docker-btns { position: absolute; top: 0; left: 50px; bottom: 0; right: 50px; display: flex; justify-content: center; align-items: center; flex-direction: column; } /* 三钮更快显隐,与坞尺寸解耦 */ .hld__docker .hld__docker-btns>div { opacity: 0; flex-shrink: 0; pointer-events: none; transition: opacity .06s ease; } .hld__docker:hover .hld__docker-btns>div, .hld__docker.is-hover .hld__docker-btns>div { opacity: 1; pointer-events: auto; } .hld__docker:has(.hld__docker-sidebar:hover) .hld__docker-btns>div { opacity: 1; pointer-events: auto; } .hld__docker.xdex-docker-boot .hld__docker-btns>div { transition: none; } /* 上一串/下一串/TOP/BOTTOM:面积缩小为原本的 2/3(直径 50px → 约 41px);REPLY 保持 50px;间隙 10px → 7px(缩 1/3) */ .hld__docker-btns>div { background: #fff; border: 1px solid #CCC; box-shadow: 0 0 1px #444; width: 50px; height: 50px; border-radius: 50%; margin: 7px 0; cursor: pointer; display: flex; justify-content: center; align-items: center; font-size: 20px; font-weight: bold; color: #333; transition: background .12s ease, transform .12s ease, opacity .06s ease; } .hld__docker-btns>div:hover { background: #f0f0f0; transform: scale(1.1); } .hld__docker-btns>div svg { display: block; } .hld__docker-btns>div svg path { fill: currentColor; } .hld__docker-btns>div[data-type="PREV-THREAD"], .hld__docker-btns>div[data-type="NEXT-THREAD"], .hld__docker-btns>div[data-type="TOP"], .hld__docker-btns>div[data-type="BOTTOM"] { width: 41px; height: 41px; font-size: 16px; } /* 固定模式:dock 收窄贴右、三按钮常显、隐藏左侧把手(不依赖 hover/:has,回退机制不受影响) 提高特异性压过 qp-style 后注入的 :hover/is-hover/:has 展开规则,固定下悬浮不抖动 */ .hld__docker.xdex-dock-fixed { width: 60px; height: 300px; bottom: 75px; } .hld__docker.xdex-dock-fixed:hover, .hld__docker.xdex-dock-fixed.is-hover, .hld__docker.xdex-dock-fixed:has(.hld__docker-sidebar:hover) { width: 60px; height: 300px; bottom: 75px; } .hld__docker.xdex-dock-fixed .hld__docker-sidebar { display: none; } .hld__docker.xdex-dock-fixed .hld__docker-btns { left: 0; right: 0; } .hld__docker.xdex-dock-fixed .hld__docker-btns>div { opacity: 1; pointer-events: auto; } .hld__docker.xdex-dock-boot.xdex-dock-fixed { transition: none; } `; } function logRightSidebarDocker(stage, detail) { try { console.log('[rightSidebarDocker]', stage, detail || {}); } catch (e) {} } function getRightSidebarReplyForm() { return document.querySelector('form[action="/Home/Forum/doReplyThread.html"]') || document.querySelector('#h-post-form form[action="/Home/Forum/doPostThread.html"]'); } // 旧核可能不认 scrollTo({ top, behavior });先对象写法,失败再回退坐标写法。 function scrollPageByDockerButton(kind) { const beforeY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; const maxY = Math.max( document.documentElement.scrollHeight || 0, document.body.scrollHeight || 0 ); const targetY = kind === 'TOP' ? 0 : maxY; let method = 'none'; let errorMessage = ''; try { if (typeof window.scrollTo === 'function') { try { window.scrollTo({ top: targetY, left: 0, behavior: 'smooth' }); method = 'scrollTo-options-smooth'; } catch (optionsErr) { window.scrollTo(0, targetY); method = 'scrollTo-coords-after-options-throw'; errorMessage = optionsErr && optionsErr.message ? optionsErr.message : String(optionsErr); } } else if (typeof window.scroll === 'function') { window.scroll(0, targetY); method = 'scroll-coords'; } else { document.documentElement.scrollTop = targetY; if (document.body) document.body.scrollTop = targetY; method = 'scrollTop-assign'; } } catch (err) { errorMessage = err && err.message ? err.message : String(err); try { document.documentElement.scrollTop = targetY; if (document.body) document.body.scrollTop = targetY; method = method === 'none' ? 'scrollTop-assign-fallback' : (method + '+scrollTop-fallback'); } catch (err2) { errorMessage = (errorMessage ? errorMessage + ' | ' : '') + (err2 && err2.message ? err2.message : String(err2)); method = 'failed'; } } // 下一帧核对是否真的动了;旧核常“吃掉 options 且不抛错”,需再回退坐标写法 setTimeout(() => { let afterY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; let moved = Math.abs(afterY - beforeY) > 1 || (kind === 'TOP' && afterY <= 1); // TOP 时 before 已接近 0 也算到位;BOTTOM 需明显靠近 target if (kind === 'BOTTOM') { moved = Math.abs(afterY - beforeY) > 1 || afterY >= Math.max(0, targetY - 50); } if (!moved && method.indexOf('scrollTo-options') === 0) { try { window.scrollTo(0, targetY); method = 'scrollTo-coords-after-options-noop'; afterY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; moved = Math.abs(afterY - beforeY) > 1 || (kind === 'TOP' ? afterY <= 1 : afterY >= Math.max(0, targetY - 50)); } catch (coordErr) { errorMessage = (errorMessage ? errorMessage + ' | ' : '') + (coordErr && coordErr.message ? coordErr.message : String(coordErr)); try { document.documentElement.scrollTop = targetY; if (document.body) document.body.scrollTop = targetY; method = 'scrollTop-assign-after-options-noop'; afterY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; moved = Math.abs(afterY - beforeY) > 1 || (kind === 'TOP' ? afterY <= 1 : afterY >= Math.max(0, targetY - 50)); } catch (err3) { method = 'failed'; errorMessage = (errorMessage ? errorMessage + ' | ' : '') + (err3 && err3.message ? err3.message : String(err3)); } } } logRightSidebarDocker('scroll-result', { kind, method, beforeY, targetY, afterY, moved, errorMessage: errorMessage || undefined, supportsScrollTo: typeof window.scrollTo === 'function', }); // BOTTOM:图片/无缝内容可能使页面在滚动中继续增高,延迟后再校正一次 if (kind === 'BOTTOM') { setTimeout(() => { try { const maxY2 = Math.max(document.documentElement.scrollHeight || 0, document.body.scrollHeight || 0); const y2 = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; if (y2 < maxY2 - 30) { window.scrollTo(0, maxY2); logRightSidebarDocker('bottom-realign', { from: y2, to: maxY2 }); } } catch (e) {} }, 500); } }, 50); logRightSidebarDocker('scroll-attempt', { kind, method, beforeY, targetY, errorMessage: errorMessage || undefined, }); } function openRightSidebarReplyWhenReady(retry = 0) { const hasForm = getRightSidebarReplyForm(); logRightSidebarDocker('reply-click', { retry, hasForm: !!hasForm, formAction: hasForm ? (hasForm.getAttribute('action') || '') : '', readyState: document.readyState, }); if (hasForm) { try { logRightSidebarDocker('reply-open-start', { retry, hasController: typeof ensureRightSidebarReplyController === 'function', }); ensureRightSidebarReplyController().open(); logRightSidebarDocker('reply-open-called', { retry, ok: true }); } catch (err) { logRightSidebarDocker('reply-open-called', { retry, ok: false, errorMessage: err && err.message ? err.message : String(err), errorStack: err && err.stack ? String(err.stack).slice(0, 500) : undefined, }); if (typeof toast === 'function') toast('打开回复浮窗失败,请查看控制台日志'); } return; } if (retry < 12) { logRightSidebarDocker('reply-wait-form', { retry, nextRetryInMs: 80 }); setTimeout(() => openRightSidebarReplyWhenReady(retry + 1), 80); return; } logRightSidebarDocker('reply-form-missing', { retry, replyFormCount: document.querySelectorAll('form[action="/Home/Forum/doReplyThread.html"]').length, postFormCount: document.querySelectorAll('#h-post-form form[action="/Home/Forum/doPostThread.html"]').length, }); if (typeof toast === 'function') toast('未找到回复/发串表单'); } function bindRightSidebarReplyButton(docker) { if (!docker || !docker.querySelector) { logRightSidebarDocker('reply-bind-skip', { reason: 'no-docker' }); return; } const replyBtn = docker.querySelector('[data-type="REPLY"]'); if (!replyBtn) { logRightSidebarDocker('reply-bind-skip', { reason: 'no-reply-btn' }); return; } if (replyBtn.dataset.xdexReplyDockBound === '1') { logRightSidebarDocker('reply-bind-skip', { reason: 'already-bound' }); return; } replyBtn.dataset.xdexReplyDockBound = '1'; replyBtn.addEventListener('click', function onDockerReplyClick(e) { logRightSidebarDocker('reply-btn-event', { type: e && e.type, readyState: document.readyState, }); openRightSidebarReplyWhenReady(); }); logRightSidebarDocker('reply-bind-ok', { bound: true, hasClickListenerMarker: replyBtn.dataset.xdexReplyDockBound === '1', }); } function bindRightSidebarShellButtons(docker) { if (!docker) { logRightSidebarDocker('shell-bind-skip', { reason: 'no-docker' }); return; } const supportsHasSelector = !!(window.CSS && CSS.supports && CSS.supports('selector(:has(*))')); const alreadyBound = docker.dataset.xdexSidebarShellBound === '1'; if (!alreadyBound) { docker.dataset.xdexSidebarShellBound = '1'; if (!supportsHasSelector) { docker.addEventListener('mouseenter', function onDockerEnter() { docker.classList.add('is-hover'); }); docker.addEventListener('mouseleave', function onDockerLeave() { docker.classList.remove('is-hover'); }); } const topBtn = docker.querySelector('[data-type="TOP"]'); const bottomBtn = docker.querySelector('[data-type="BOTTOM"]'); if (topBtn) { topBtn.addEventListener('click', function onDockerTopClick(e) { logRightSidebarDocker('top-btn-event', { type: e && e.type }); scrollPageByDockerButton('TOP'); }); } if (bottomBtn) { bottomBtn.addEventListener('click', function onDockerBottomClick(e) { logRightSidebarDocker('bottom-btn-event', { type: e && e.type }); scrollPageByDockerButton('BOTTOM'); }); } // 版块页/时间线页:上一串/下一串(REPLY 居中) const prevBtn = docker.querySelector('[data-type="PREV-THREAD"]'); const nextBtn = docker.querySelector('[data-type="NEXT-THREAD"]'); if (prevBtn) { prevBtn.addEventListener('click', function onDockerPrevThreadClick(e) { logRightSidebarDocker('prev-thread-btn-event', { type: e && e.type }); scrollToAdjacentThread(-1); }); } if (nextBtn) { nextBtn.addEventListener('click', function onDockerNextThreadClick(e) { logRightSidebarDocker('next-thread-btn-event', { type: e && e.type }); scrollToAdjacentThread(1); }); } logRightSidebarDocker('shell-bind-ok', { supportsHasSelector, hoverFallbackBound: !supportsHasSelector, topBound: !!topBtn, bottomBound: !!bottomBtn, prevThreadBound: !!prevBtn, nextThreadBound: !!nextBtn, topTag: topBtn ? topBtn.tagName : '', bottomTag: bottomBtn ? bottomBtn.tagName : '', }); } else { logRightSidebarDocker('shell-bind-skip', { reason: 'already-bound', supportsHasSelector, }); } bindRightSidebarReplyButton(docker); } function markRightSidebarDockerBootMotion(docker) { if (!docker || docker.dataset.xdexBootMotionDone === '1') return; docker.classList.add('xdex-docker-boot'); const clear = () => { docker.classList.remove('xdex-docker-boot'); docker.dataset.xdexBootMotionDone = '1'; }; // 覆盖 ready 同步 + batch1/2 高峰;之后恢复短过渡 const timer = window.setTimeout(clear, 1500); docker.dataset.xdexBootMotionTimer = String(timer); } function scheduleReplyOverlayStylePrefetch() { if (scheduleReplyOverlayStylePrefetch.__scheduled) return; scheduleReplyOverlayStylePrefetch.__scheduled = true; const run = () => { try { if (!document.getElementById('qp-style') && typeof ensureReplyOverlayStyle === 'function') { ensureReplyOverlayStyle(); } if (typeof ensureDarkReaderThemeObserver === 'function') ensureDarkReaderThemeObserver(); } catch (e) {} }; // 空闲时预热浮窗样式,避免 batch2 与首次悬停抢主线程;首次点 REPLY 仍有 open() 兜底 if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => run(), { timeout: 2500 }); } else { window.setTimeout(run, 1800); } } function tryReplaceRightSidebarEarly() { if (!document.body) return false; ensureRightSidebarShellStyle(); // 尽早同步 Dark Reader class:设置面板暗色依赖它,不能等 qp-style / batch2 try { ensureDarkReaderThemeSync(); } catch (e) {} const nativeTool = document.querySelector('#h-tool'); if (nativeTool) nativeTool.remove(); let docker = document.querySelector('.hld__docker'); if (!docker) { document.body.insertAdjacentHTML('beforeend', buildRightSidebarDockerHtml()); docker = document.querySelector('.hld__docker'); if (docker) docker.dataset.xdexEarlyDocker = '1'; } if (docker) markRightSidebarDockerBootMotion(docker); // 按设置应用「固定/隐藏」模式(不触碰 :has 回退逻辑) if (docker) applyDockDisplayMode(getDockDisplayMode()); bindRightSidebarShellButtons(docker); return !!docker; } function getDockDisplayMode() { try { const stateMode = SettingPanel && SettingPanel.state && SettingPanel.state.dockDisplayMode; if (stateMode === 'fixed' || stateMode === 'hover') return stateMode; const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); const m = cfg.dockDisplayMode; if (m === 'fixed' || m === 'hover') return m; } catch (e) {} return 'fixed'; } function applyDockDisplayMode(mode) { const docker = document.querySelector('.hld__docker'); if (!docker) return; const fixed = mode === 'fixed'; docker.classList.toggle('xdex-dock-fixed', fixed); console.log('[rightSidebarDocker] 显示模式应用', { mode: fixed ? 'fixed' : 'hover' }); } function isDarkReaderActive() { const root = document.documentElement; if (!root) return false; const mode = root.getAttribute('data-darkreader-mode'); const scheme = root.getAttribute('data-darkreader-scheme'); return !!(mode && mode !== 'off') || !!(scheme && scheme !== 'off'); } function getReplyOverlayThemeTokens(dark) { // 用 !important 写回具体颜色,避免 Dark Reader 后续改写 CSS 变量 / 反色后边框跑偏。 return dark ? { shellBg: '#2b2c2d', formBg: '#2b2c2d', previewBg: '#483327', textareaBg: '#1f2021', border: '#4b4d50', outline: '#3a3c3f', shadow: 'rgba(0,0,0,.55)', resetBg: 'rgba(19,20,21,.88)', resetColor: '#e8e6e3', } : { shellBg: '#FFFFEE', formBg: '#FFFFEE', previewBg: '#F0E0D6', textareaBg: '#fff', border: '#ccc', outline: '#fff', shadow: 'rgba(0,0,0,.24)', resetBg: 'rgba(0,0,0,.6)', resetColor: '#fff', }; } // 自定义深色是否生效:仅当设置开启且 Dark Reader 未激活时 function isCustomDarkActive() { try { if (isDarkReaderActive()) return false; // DR 激活 → 由 DR 定色,脚本深色停用 return SettingPanel.state.enableCustomDarkTheme === 'dark'; } catch (e) { return false; } } // 深色开关视觉:轨道白/黑、日/月图标切换、滑块位移 function syncDarkToggleVisual() { const cb = document.getElementById('sp_enableCustomDarkTheme'); const track = document.getElementById('sp_dark_toggle_track'); const thumb = document.getElementById('sp_dark_toggle_thumb'); const sun = document.getElementById('sp_dark_icon_sun'); const moon = document.getElementById('sp_dark_icon_moon'); if (!cb || !track || !thumb) return; const on = !!cb.checked; // 深色轨道用淡黑(#3a3a3a),比月亮图标的黑色浅、比滑块底深,保证月亮可读 track.style.background = on ? '#3a3a3a' : '#fff'; track.style.borderColor = on ? '#555' : '#ccc'; thumb.style.background = on ? '#2b2b2b' : '#fff'; thumb.style.transform = on ? 'translateX(22px)' : 'translateX(0)'; if (sun) sun.style.display = on ? 'none' : ''; if (moon) moon.style.display = on ? '' : 'none'; } window.__xdexSyncDarkToggleVisual = syncDarkToggleVisual; // 整页深色同步:打 xdex-custom-dark 类控制覆盖样式;脚本 UI 沿用 xdex-darkreader-active 暗色规则 function syncCustomPageTheme() { const root = document.documentElement; if (!root) return; // state 未就绪守卫:SettingPanel.init() 在 $(document).ready 里才填充 state, // 早于此的同步(tryReplaceRightSidebarEarly → ensureDarkReaderThemeSync)读到空 state // 会按 false 处理——摘掉 early 刚打的类并把 sessionStorage 毒化为 'false', // 下次刷新 early 判定即失败 → 开屏闪浅。守卫期间保持 early 注入状态并写入 'true'。 if (!SettingPanel.state || !('enableCustomDarkTheme' in SettingPanel.state)) { try { sessionStorage.setItem(XDEX_DARK_SESSION_KEY, 'true'); } catch (e) {} return; } const on = isCustomDarkActive(); console.log('[xdex-theme-sync] stateLoaded=' + ('enableCustomDarkTheme' in (SettingPanel.state || {})) + ' on=' + on + ' dr=' + isDarkReaderActive() + ' hadEarlyClass=' + root.classList.contains('xdex-custom-dark')); // 维护会话记忆:下次刷新 document-start 阶段同步可读,避免等异步 GM 存储造成闪浅 try { sessionStorage.setItem(XDEX_DARK_SESSION_KEY, on ? 'true' : 'false'); } catch (e) {} root.classList.toggle('xdex-custom-dark', on); document.documentElement.style.colorScheme = (on || isDarkReaderActive()) ? 'dark' : 'light'; if (on) { // early fallback 已完成首帧使命:完整规则接管后删除(避免两份样式冲突) const early = document.getElementById('xdex-custom-theme-early'); if (early) early.remove(); refreshCustomDarkThemeStyle(); scheduleRefreshCustomDarkTheme(); // 首轮枚举完成后(含 fetch 兜底)再补一次完整表 } else { const s = document.getElementById('xdex-custom-theme'); if (s) s.textContent = ''; const early = document.getElementById('xdex-custom-theme-early'); if (early) early.remove(); } } // —— 深色配色算法(方案C):不维护映射表,按 Dark Reader 规律做 HSL 变换 —— // 规律(由 DR 实测映射反推):色相保持;亮度反转并压缩(亮→暗乘 0.16~0.22,暗→亮乘 0.85); // 饱和度乘 0.8;低饱和(S<0.1)统一转 210° 微蓝灰(S≈0.02~0.08) function xdexHexToRgb(hex) { let h = String(hex || '').replace(/^#/, ''); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); if (!/^[0-9a-fA-F]{6}$/.test(h)) return null; return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; } function xdexRgbToHex(r, g, b) { const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'); return '#' + c(r) + c(g) + c(b); } function xdexRgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); const l = (max + min) / 2; if (max === min) return [0, 0, l]; const d = max - min; const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); let h; if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)); else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h /= 6; return [h, s, l]; } function xdexHslToRgb(h, s, l) { if (s === 0) { const v = l * 255; return [v, v, v]; } const hue2 = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; return [hue2(p, q, h + 1 / 3) * 255, hue2(p, q, h) * 255, hue2(p, q, h - 1 / 3) * 255]; } // 原色 → 深色主题色:亮变暗、暗变亮、饱和压缩、灰阶转微蓝灰 function xdexDarkColor(hex) { const rgb = xdexHexToRgb(hex); if (!rgb) return null; const [h, s, l] = xdexRgbToHsl(rgb[0], rgb[1], rgb[2]); if (l > 0.93) { // 近白底(#ffe/#fff 页面底)→ 一律中性化为深底(DR 实测同样处理) const [r, g, b] = xdexHslToRgb(210 / 360, 0.02, 0.16); return xdexRgbToHex(r, g, b); } if (s < 0.1) { if (l >= 0.5 && l <= 0.78) { // 中灰(#999/#aaa 级)→ 保持中亮,仅转微蓝灰(DR 实测:#999→#a29b92,不反转为深底) const [r, g, b] = xdexHslToRgb(210 / 360, 0.07, Math.min(0.66, l + 0.02)); return xdexRgbToHex(r, g, b); } // 其余灰阶 → 按原亮度提亮为蓝灰(DR 映射规律:#707070→#9c958b 是提亮而非压暗) const dark = l < 0.1 ? 0.07 : (l < 0.3 ? 0.35 : Math.min(0.72, l + 0.22)); const [r, g, b] = xdexHslToRgb(210 / 360, 0.08, dark); return xdexRgbToHex(r, g, b); } // 亮度反转压缩:亮→暗区,暗→亮区 let l2; if (l >= 0.5) l2 = 0.5 - (l - 0.5) * 0.62; // 0.97→0.19、0.89→0.25、0.73→0.42 else l2 = 0.5 + (0.5 - l) * 0.85; // 0.25→0.69、0.37→0.56、0.43→0.62 const s2 = Math.min(1, s * 0.8); const [r, g, b] = xdexHslToRgb(h, s2, l2); return xdexRgbToHex(r, g, b); } // 前景白化:正文类文字不保色相,向白灰收敛(主流深色主题策略,可读性优先) // l2 = 亮度反转后与目标白灰按比例混合;饱和度随混合比例同步衰减 function xdexDarkForeground(hex) { const rgb = xdexHexToRgb(hex); if (!rgb) return null; const [h, s, l] = xdexRgbToHsl(rgb[0], rgb[1], rgb[2]); if (l > 0.93 || s < 0.1) return xdexDarkColor(hex); // 底色/灰阶走通用分支 let l2 = l >= 0.5 ? 0.5 - (l - 0.5) * 0.62 : 0.5 + (0.5 - l) * 0.85; // 混入 88% 目标白灰 #d8d7d4(L≈0.85, S≈0.02),仅保留 12% 原反转结果(近乎纯白灰,粉调不可见) const mixL = l2 * 0.12 + 0.85 * 0.88; const mixS = s * 0.8 * 0.12; const [r, g, b] = xdexHslToRgb(h, mixS, mixL); return xdexRgbToHex(r, g, b); } // 页面原生样式表来源(与站点实际引用一致;uikit 为 CDN 时走 fetch 兜底) const XDEX_DARK_THEME_SHEET_URLS = [ location.origin + '/Public/Css/h.desktop.css', 'https://www.nmbxd1.com/Public/Css/h.desktop.css' ]; // 从 CSS 文本收集颜色值并生成 :root.xdex-custom-dark 全局反转规则 function xdexCollectColorsFromCssText(cssText, colorSet) { if (!cssText) return; const re = /(#[0-9a-fA-F]{3}\b|#[0-9a-fA-F]{6}\b|rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+)?\s*\))/g; let m; while ((m = re.exec(cssText))) { const token = m[1]; if (token.startsWith('#')) colorSet.add(token.toLowerCase()); else { const nums = token.match(/\d+/g); if (nums && nums.length >= 3) { colorSet.add(xdexRgbToHex(+nums[0], +nums[1], +nums[2]).toLowerCase()); } } } } // 生成全局色彩反转规则:对页面全部颜色类做类名切换(性能远优于逐选择器覆盖) const xdexDarkCache = new Map(); function xdexToDarkCached(hex) { if (!xdexDarkCache.has(hex)) xdexDarkCache.set(hex, xdexDarkColor(hex) || hex); return xdexDarkCache.get(hex); } function buildCustomDarkThemeStyleText(colorSet) { const D = (hex) => xdexToDarkCached(hex); const F = (hex) => xdexDarkForeground(hex) || D(hex); // 前景白化(正文可读性优先) const lines = [ '/* generated by xdex custom dark (algorithm-based) */', ':root.xdex-custom-dark { color-scheme: dark; }', // 页面底与正文:正文文字走前景白化(可读性优先),底色走通用反转 // 注意::root 即 html 本身,":root.xdex-custom-dark html" 是永不匹配的死选择器 'html.xdex-custom-dark, html.xdex-custom-dark body { background-color: ' + D('#ffe') + ' !important; color: ' + F('#800000') + ' !important; }', // 左侧菜单(不用白化:保留原反转色彩) ':root.xdex-custom-dark #h-menu { background-color: ' + D('#fff') + ' !important; color: ' + D('#c00') + ' !important; border-right-color: ' + D('#000') + ' !important; }', ':root.xdex-custom-dark #h-menu #h-menu-content .h-nav-parent-header, :root.xdex-custom-dark #h-menu #h-menu-content .h-nav-header { color: ' + D('#c00') + ' !important; }', ':root.xdex-custom-dark #h-menu #h-menu-content .h-active { background: ' + D('#ea8') + ' !important; }', ':root.xdex-custom-dark #h-menu #h-menu-content .h-active a { color: ' + D('#800000') + ' !important; }', ':root.xdex-custom-dark #h-menu #h-menu-content .h-nav-item a { color: ' + D('#07d') + ' !important; }', ':root.xdex-custom-dark #h-menu #h-menu-content .h-nav-item:hover a { color: ' + D('#059') + ' !important; }', // 底部导航与工具 ':root.xdex-custom-dark #h-bottom-nav { background: ' + D('#fff') + ' !important; border-top-color: ' + D('#000') + ' !important; }', ':root.xdex-custom-dark #h-tool .h-tool-btn { background: ' + D('#b00') + ' !important; }', // 正文信息行:标题/邮箱保色相(功能性红绿);info 通用通配不再扫掉彩色(红字绿字变灰的根因) ':root.xdex-custom-dark #h-content h2.h-title, :root.xdex-custom-dark .h-threads-item .h-threads-info .h-threads-info-title, :root.xdex-custom-dark .h-threads-info .h-threads-info-title { color: ' + D('#cc1105') + ' !important; }', ':root.xdex-custom-dark .h-threads-item .h-threads-info .h-threads-info-email, :root.xdex-custom-dark .h-threads-info .h-threads-info-email { color: ' + D('#117743') + ' !important; }', // SAGE 警示行(uk-text-danger 原生红)保持警示红,不走 tips 灰 ':root.xdex-custom-dark .h-threads-item .h-threads-tips.uk-text-danger, :root.xdex-custom-dark .h-threads-tips.uk-text-danger { color: ' + D('#cc1105') + ' !important; }', ':root.xdex-custom-dark .h-threads-item .h-threads-tips, :root.xdex-custom-dark .h-threads-tips { color: ' + F('#707070') + ' !important; }', // 回复块 ':root.xdex-custom-dark .h-threads-item .h-threads-item-replies .h-threads-item-reply .h-threads-item-reply-main { background: ' + D('#f0e0d6') + ' !important; }', // 信息行链接与正文链接(No.xxx/举报/回应/订阅/外部链接):uikit 全局 a 色 #1e87f0 落在深底上偏暗, // 统一提亮至与分页/浮窗一致的亮蓝(DR 级观感) ':root.xdex-custom-dark .h-threads-item .h-threads-info a, :root.xdex-custom-dark .h-threads-content a { color: ' + D('#07d') + ' !important; }', // PO主标记(uk-text-primary,uikit 主题蓝):同样提亮 ':root.xdex-custom-dark .h-threads-item .uk-text-primary { color: ' + D('#07d') + ' !important; }', // 分页 ':root.xdex-custom-dark .h-pagination li a, :root.xdex-custom-dark .h-pagination li span { color: ' + D('#07d') + ' !important; background: ' + D('#fff') + ' !important; }', ':root.xdex-custom-dark .h-pagination li:hover a { color: ' + D('#059') + ' !important; }', ':root.xdex-custom-dark .h-pagination li.uk-active a, :root.xdex-custom-dark .h-pagination li.uk-active span { color: ' + F('#800000') + ' !important; background: ' + D('#b00') + ' !important; }', // 引用号 ':root.xdex-custom-dark font[color="#789922"] { color: ' + D('#789922') + ' !important; }', // 隐藏文本 ':root.xdex-custom-dark .h-hidden-text { background: ' + D('#666') + ' !important; color: transparent !important; }', ':root.xdex-custom-dark .h-hidden-text:hover { background: transparent !important; color: ' + F('#800000') + ' !important; }', // 原生引用浮窗与发帖表单标题栏 ':root.xdex-custom-dark #h-ref-view .h-threads-item-ref, :root.xdex-custom-dark #h-ref-view .uk-container { background: ' + D('#f0e0d6') + ' !important; }', ':root.xdex-custom-dark #h-post-form .h-post-form-title { background: ' + D('#ea8') + ' !important; }', // —— 脚本自建 UI 深色(修复覆盖不足):通用前景/背景反转 —— ':root.xdex-custom-dark .qp-quote { color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark .qp-header .qp-level, :root.xdex-custom-dark .qp-header .qp-back, :root.xdex-custom-dark .qp-header .qp-jump { color: ' + F('#800000') + ' !important; }', // 颜文字面板 ':root.xdex-custom-dark .kaomoji-panel, :root.xdex-custom-dark .kaomoji-popup, :root.xdex-custom-dark .kaomoji-dropdown { background: ' + D('#fff') + ' !important; color: ' + F('#800000') + ' !important; border-color: ' + D('#000') + ' !important; }', ':root.xdex-custom-dark .kaomoji-panel *, :root.xdex-custom-dark .kaomoji-popup *, :root.xdex-custom-dark .kaomoji-dropdown * { color: ' + F('#800000') + ' !important; }', // 回复浮窗内的原生片段:默认白化,但功能性彩色(title/email/引用/链接/SAGE)保色相 ':root.xdex-custom-dark .qp-body .h-threads-info, :root.xdex-custom-dark .qp-body .h-threads-info *, :root.xdex-custom-dark .qp-body .h-threads-content, :root.xdex-custom-dark .qp-body .h-threads-content * { color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark .qp-body .h-threads-info-title { color: ' + D('#cc1105') + ' !important; }', ':root.xdex-custom-dark .qp-body .h-threads-info-email { color: ' + D('#117743') + ' !important; }', ':root.xdex-custom-dark .qp-body .h-threads-tips.uk-text-danger { color: ' + D('#cc1105') + ' !important; }', ':root.xdex-custom-dark .qp-body .h-threads-info a, :root.xdex-custom-dark .qp-body .h-threads-content a { color: ' + D('#07d') + ' !important; }', ':root.xdex-custom-dark .qp-body { background: ' + D('#ffe') + ' !important; }', // 预览框(发送前实时预览):与回复块完全同构 —— 本体兜底深棕底+浅字, // info/content 白化后跟保色规则(链接/PO主/引用绿),特异性白化规则更高 ':root.xdex-custom-dark .h-preview-box { background: ' + D('#f0e0d6') + ' !important; color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark .h-preview-box .h-threads-item-reply-main { background: ' + D('#f0e0d6') + ' !important; }', ':root.xdex-custom-dark .h-preview-box .h-threads-info-title { color: ' + D('#cc1105') + ' !important; }', ':root.xdex-custom-dark .h-preview-box .h-threads-info-email { color: ' + D('#117743') + ' !important; }', ':root.xdex-custom-dark .h-preview-box .h-threads-info, :root.xdex-custom-dark .h-preview-box .h-threads-info *, :root.xdex-custom-dark .h-preview-box .h-threads-content, :root.xdex-custom-dark .h-preview-box .h-threads-content * { color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark .h-preview-box font[color="#789922"] { color: ' + D('#789922') + ' !important; }', ':root.xdex-custom-dark .h-preview-box .h-threads-info a, :root.xdex-custom-dark .h-preview-box .h-threads-content a { color: ' + D('#07d') + ' !important; }', ':root.xdex-custom-dark .h-preview-box .uk-text-primary { color: ' + D('#07d') + ' !important; }', ':root.xdex-custom-dark #cookie-confirm-modal { color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark #cookie-confirm-modal h3 { color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark #cookie-confirm-modal p { color: ' + F('#707070') + ' !important; }', ':root.xdex-custom-dark #cookie-confirm-modal p font[color="#789922"] { color: ' + D('#789922') + ' !important; }', ':root.xdex-custom-dark #cookie-confirm-list [data-cookie-id] { color: ' + F('#800000') + ' !important; background-color: ' + D('#fafafa') + ' !important; }', ':root.xdex-custom-dark #cookie-confirm-list .h-threads-info-uid { color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark #cookie-confirm-actions button { background: ' + D('#fafafa') + ' !important; color: ' + F('#800000') + ' !important; border: 1px solid ' + D('#bbb') + ' !important; border-radius: 6px; cursor: pointer; }', // 设置面板分组输入行(标记/屏蔽/只看/常用串/订阅/关键词):容器去白底、输入框深底浅字、徽标/删除钮暗底 ':root.xdex-custom-dark #marked-inputs-container > div > div, :root.xdex-custom-dark #blocked-inputs-container > div > div, :root.xdex-custom-dark #favorite-thread-inputs-container > div > div, :root.xdex-custom-dark #subscription-feed-inputs-container > div > div, :root.xdex-custom-dark #thread-cookie-whitelist-inputs-container > div > div, :root.xdex-custom-dark #blocked-keyword-inputs-container > div > div { background: ' + D('#fafafa') + ' !important; border-color: ' + D('#a98f7a') + ' !important; }', // input 深底浅字,但排除 xdex-switch(正则开关保红/蓝语义) ':root.xdex-custom-dark #marked-inputs-container input:not(.xdex-switch), :root.xdex-custom-dark #blocked-inputs-container input:not(.xdex-switch), :root.xdex-custom-dark #favorite-thread-inputs-container input:not(.xdex-switch), :root.xdex-custom-dark #subscription-feed-inputs-container input:not(.xdex-switch), :root.xdex-custom-dark #thread-cookie-whitelist-inputs-container input:not(.xdex-switch), :root.xdex-custom-dark #blocked-keyword-inputs-container input:not(.xdex-switch), :root.xdex-custom-dark #blocked-keyword-inputs-container select { background: ' + D('#fff') + ' !important; color: ' + F('#800000') + ' !important; border-color: ' + D('#bbb') + ' !important; }', // button 深底浅字,但排除 marked-color-swatch(保持用户选定的标记色) ':root.xdex-custom-dark #marked-inputs-container button:not(.marked-color-swatch), :root.xdex-custom-dark #blocked-inputs-container button, :root.xdex-custom-dark #favorite-thread-inputs-container button, :root.xdex-custom-dark #subscription-feed-inputs-container button, :root.xdex-custom-dark #thread-cookie-whitelist-inputs-container button, :root.xdex-custom-dark #blocked-keyword-inputs-container button { background: ' + D('#fafafa') + ' !important; color: ' + F('#800000') + ' !important; border-color: ' + D('#a98f7a') + ' !important; }', ':root.xdex-custom-dark #marked-inputs-container span, :root.xdex-custom-dark #blocked-inputs-container span, :root.xdex-custom-dark #favorite-thread-inputs-container span, :root.xdex-custom-dark #subscription-feed-inputs-container span, :root.xdex-custom-dark #thread-cookie-whitelist-inputs-container span, :root.xdex-custom-dark #blocked-keyword-inputs-container span { background: ' + D('#fafafa') + ' !important; color: ' + F('#800000') + ' !important; }', // 常用串菜单(脚本自建侧栏) ':root.xdex-custom-dark .xdex-fav-threads-menu, :root.xdex-custom-dark .xdex-fav-threads-menu * { background-color: ' + D('#fff') + ' !important; color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark .xdex-fav-threads-menu .h-active, :root.xdex-custom-dark .xdex-fav-threads-menu .h-active * { background: ' + D('#ea8') + ' !important; }', // 饼干切换模块:当前饼干名白色字体(含异常红色状态排除) ':root.xdex-custom-dark #current-cookie-display { color: #d8d7d4 !important; }', ':root.xdex-custom-dark #current-cookie-display[style*="red"], :root.xdex-custom-dark #current-cookie-display[style*="rgb(255, 0, 0)"] { color: #e85248 !important; }', // 折叠占位按钮(发送表单可选项/关键词/饼干屏蔽折叠区通用) ':root.xdex-custom-dark .xdex-placeholder.xdex-generic-toggle, :root.xdex-custom-dark .xdex-placeholder { background: ' + D('#fafafa') + ' !important; color: ' + F('#888') + ' !important; border-color: ' + D('#bbb') + ' !important; }', // 外部图床图片预览折叠容器(.injected-image-container / .iic-*):内联浅灰系,深色下整块刺眼 ':root.xdex-custom-dark .injected-image-container { background: ' + D('#f9f9f9') + ' !important; border-color: ' + D('#ddd') + ' !important; }', ':root.xdex-custom-dark .iic-header { background: ' + D('#f2f2f2') + ' !important; border-bottom-color: ' + D('#e6e6e6') + ' !important; }', ':root.xdex-custom-dark .iic-title { color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark .iic-footer { background: ' + D('#f9f9f9') + ' !important; border-top-color: ' + D('#eee') + ' !important; }', ':root.xdex-custom-dark .iic-more-btn-top, :root.xdex-custom-dark .iic-toggle-btn, :root.xdex-custom-dark .iic-more-btn-bottom { background: ' + D('#fff') + ' !important; color: ' + F('#800000') + ' !important; border-color: ' + D('#ccc') + ' !important; }', ':root.xdex-custom-dark .iic-body img { border-color: ' + D('#ccc') + ' !important; }', ':root.xdex-custom-dark .iic-body > div { color: ' + F('#707070') + ' !important; }', // 颜文字选择框:原生 select 亮白 → 深色 ':root.xdex-custom-dark #h-emot-select { background: ' + D('#fff') + ' !important; color: ' + F('#800000') + ' !important; border-color: ' + D('#bbb') + ' !important; }', ':root.xdex-custom-dark #h-emot-select option { background: ' + D('#fff') + ' !important; color: ' + F('#800000') + ' !important; }', // 颜文字触发按钮与键盘选中项 ':root.xdex-custom-dark .kaomoji-trigger { background: ' + D('#fafafa') + ' !important; color: ' + F('#800000') + ' !important; border-color: ' + D('#bbb') + ' !important; }', ':root.xdex-custom-dark .kaomoji-item.kaomoji-active { background: #3a3d40 !important; }', ':root.xdex-custom-dark .kaomoji-item { color: ' + F('#800000') + ' !important; }', // 悬浮底色:压过浅色源的 #f2f2f2,用深色中灰 + 白化前景 ':root.xdex-custom-dark .kaomoji-item:hover { background: #3a3d40 !important; }', // 回应省略提示(warn_txt2):站点原生 .warn_txt2 直接命中子元素,需单独白化(容器继承管不到) ':root.xdex-custom-dark .warn_txt2 { color: ' + F('#707070') + ' !important; }', ':root.xdex-custom-dark .kaomoji-item { color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark .kaomoji-quick-sort, :root.xdex-custom-dark #h-emot-select ~ select { background: ' + D('#fff') + ' !important; color: ' + F('#800000') + ' !important; }', ':root.xdex-custom-dark html, :root.xdex-custom-dark body, :root.xdex-custom-dark #h-menu, :root.xdex-custom-dark .h-threads-item, :root.xdex-custom-dark .h-preview-box { transition: background-color 0.35s ease, color 0.35s ease, border-color 0.35s ease; }', 'html.xdex-theme-anim, html.xdex-theme-anim body, html.xdex-theme-anim #h-menu, html.xdex-theme-anim #h-content, html.xdex-theme-anim .h-threads-item, html.xdex-theme-anim .h-threads-item *, html.xdex-theme-anim .h-preview-box { transition: background-color 0.35s ease, color 0.35s ease, border-color 0.35s ease; }' ]; return lines.join('\n'); } function ensureCustomDarkThemeStyle(colorSet) { const existing = document.getElementById('xdex-custom-theme'); const text = buildCustomDarkThemeStyleText(colorSet || new Set()); if (existing) { existing.textContent = text; return existing; } const style = document.createElement('style'); style.id = 'xdex-custom-theme'; style.textContent = text; document.head.appendChild(style); return style; } // —— 样式表枚举:从页面原生 CSS 收集全部颜色,生成“原色→深色”CSS 变量表 + 通用覆盖 —— function xdexCollectColorsFromStyleSheets(colorSet) { try { for (const sheet of document.styleSheets) { let rules = null; try { rules = sheet.cssRules; } catch (e) { /* 跨域样式表:cssRules 受限,走 fetch 兜底 */ } if (rules) { const drain = (list) => { for (const rule of list) { if (rule.type === 1 /* STYLE_RULE */ && rule.style) { for (let i = 0; i < rule.style.length; i++) { const prop = rule.style[i]; if (!/color|background|border|outline|shadow|fill|stroke/i.test(prop)) continue; const val = rule.style.getPropertyValue(prop); xdexCollectColorsFromCssText(val, colorSet); } } else if (rule.cssRules) { drain(rule.cssRules); } } }; drain(rules); } else if (sheet.href && !colorSet.__fetched) { // 跨域表:异步 fetch 后重建样式(不阻塞首次注入) colorSet.__fetched = true; fetch(sheet.href).then((r) => r.text()).then((txt) => { const s2 = new Set(); xdexCollectColorsFromCssText(txt, s2); refreshCustomDarkThemeStyle(s2); }).catch(() => {}); } } } catch (e) {} } // 生成“颜色变量表 + 按值替换”的通用覆盖:用 CSS 变量把每个原色挂到 :root, // 再通过 attribute/class 无关的全局选择器无法按值匹配,故这里输出【每色一类】的变量表, // 真正的按值替换交给 styleOverridesText(对常见结构性选择器做深色赋值)。 function xdexDarkVarsText(colorSet) { const out = []; for (const hex of colorSet) { const dark = xdexToDarkCached(hex); if (!dark || dark === hex) continue; const varName = '--xdex-dark-' + hex.replace('#', ''); out.push(varName + ': ' + dark + ';'); } return out.length ? ':root.xdex-custom-dark {\n ' + out.join('\n ') + '\n}' : ''; } let refreshTimer = 0; function refreshCustomDarkThemeStyle(extraColors) { const colorSet = new Set(extraColors || []); xdexCollectColorsFromStyleSheets(colorSet); // 兜底核心色(防止样式表枚举失败时页面全白) ['#ffe', '#fff', '#800000', '#789922', '#f0e0d6', '#07d', '#c00', '#000', '#059', '#ea8', '#999', '#cc1105', '#117743', '#b00', '#707070', '#666'].forEach((c) => colorSet.add(c)); const style = ensureCustomDarkThemeStyle(colorSet); const varsText = xdexDarkVarsText(colorSet); style.textContent = buildCustomDarkThemeStyleText(colorSet) + '\n' + varsText; } function scheduleRefreshCustomDarkTheme() { if (refreshTimer) return; refreshTimer = setTimeout(() => { refreshTimer = 0; refreshCustomDarkThemeStyle(); }, 400); } function syncQuotePopupTheme() { const root = document.documentElement; if (!root) return; const dark = isDarkReaderActive(); // 脚本 UI 暗色:DR 激活 或 自定义深色开启(复用现有 :root.xdex-darkreader-active 全部暗色规则) const uiDark = dark || isCustomDarkActive(); root.classList.toggle('xdex-darkreader-active', uiDark); syncCustomPageTheme(); // DR 激活时内联 !important 钉死必须退让:DR 反转后会重写这些属性,脚本钉死即优先级反超 if (dark) { document.querySelectorAll('.qp-quote, .qp-body .qp-content-wrap, .qp-body .qp-content-wrap form, .qp-body .qp-content-wrap textarea[name="content"], .qp-reset-btn').forEach((el) => { ['background', 'border-color', 'outline-color', 'box-shadow', 'color'].forEach((p) => { try { el.style.removeProperty(p); } catch (e) {} }); }); } const theme = getReplyOverlayThemeTokens(uiDark); // CSS 变量兜底(未打开浮窗时也保持一致) root.style.setProperty('--xdex-qp-shell-bg', theme.shellBg); root.style.setProperty('--xdex-qp-form-bg', theme.formBg); root.style.setProperty('--xdex-qp-preview-bg', theme.previewBg); root.style.setProperty('--xdex-qp-textarea-bg', theme.textareaBg); root.style.setProperty('--xdex-qp-border', theme.border); root.style.setProperty('--xdex-qp-outline', theme.outline); root.style.setProperty('--xdex-qp-shadow', theme.shadow); root.style.setProperty('--xdex-qp-reset-bg', theme.resetBg); root.style.setProperty('--xdex-qp-reset-color', theme.resetColor); // 浮窗壳/边框/描边:Dark Reader 后处理常会改 border-color,必须 !important 钉死 document.querySelectorAll('.qp-quote').forEach((el) => { el.style.setProperty('background', theme.shellBg, 'important'); el.style.setProperty('border-color', theme.border, 'important'); el.style.setProperty('outline-color', theme.outline, 'important'); el.style.setProperty('box-shadow', `0 8px 24px ${theme.shadow}`, 'important'); }); document.querySelectorAll('.qp-body .qp-content-wrap, .qp-body .qp-content-wrap form').forEach((el) => { el.style.setProperty('background', theme.formBg, 'important'); }); document.querySelectorAll('.qp-body .qp-content-wrap textarea[name="content"]').forEach((el) => { el.style.setProperty('background', theme.textareaBg, 'important'); }); document.querySelectorAll('.qp-reset-btn, .qp-close-all').forEach((el) => { // 写 CSS 变量而非内联 color/background:内联 !important 会压过样式表 hover 规则, // 导致悬浮时只有边框变绿、svg(currentColor) 不变色 el.style.setProperty('--xdex-qp-reset-bg', theme.resetBg); el.style.setProperty('--xdex-qp-reset-color', theme.resetColor); el.style.setProperty('--xdex-qp-reset-border', theme.resetBg); }); document.querySelectorAll('.qp-body .qp-content-wrap .h-preview-box, .qp-body .qp-content-wrap .h-preview-box .h-threads-item, .qp-body .qp-content-wrap .h-preview-box .h-threads-item-replies, .qp-body .qp-content-wrap .h-preview-box .h-threads-item-reply, .qp-body .qp-content-wrap .h-preview-box .h-threads-item-reply-main').forEach((el) => { el.style.setProperty('background', theme.previewBg, 'important'); }); } // early open 也要能监听 Dark Reader 后续属性变化;只盯 html 上的 darkreader 属性,避免 subtree style 风暴。 function ensureDarkReaderThemeObserver() { if (window.__xdexDarkReaderThemeObserver) return window.__xdexDarkReaderThemeObserver; let scheduled = 0; const scheduleSync = () => { if (scheduled) return; scheduled = requestAnimationFrame(() => { scheduled = 0; syncQuotePopupTheme(); }); }; const observer = new MutationObserver(scheduleSync); // 先挂到 window 再 observe:early open 复用同一实例;赋值与 attributeFilter 保持相邻(契约测试就近断言) window.__xdexDarkReaderThemeObserver = observer; observer.observe(document.documentElement, { attributes: true, // DR 实际写入含 fr-init-once 与 style 内联变量(实测)→ 必须盯,否则开 DR 后仍浅色 attributeFilter: ['data-darkreader-mode', 'data-darkreader-scheme', 'fr-init-once', 'style', 'class'], }); return observer; } // 设置面板/登录框/历史等共用 :root.xdex-darkreader-active。 // 主题同步必须独立于 qp-style:batch2 可延后浮窗大 CSS,但不能延后这个 class。 function ensureDarkReaderThemeSync() { window.__xdexSyncDarkReaderTheme = syncQuotePopupTheme; ensureDarkReaderThemeObserver(); syncQuotePopupTheme(); } // early open 也必须立刻注入浮窗样式;不能等 batch2 的 replaceRightSidebar。 // 否则早期点 REPLY 会出现:表单先裸奔到左上角 → 浮窗壳后到 → 再被样式/暗色主题二次重绘。 function ensureReplyOverlayStyle() { // 始终刷新样式文本:脚本热更新后若只判断 id 存在,会继续用旧 CSS(如 height:80vh) let style = document.getElementById('qp-style'); const styleExisted = !!style; if (!style) { style = document.createElement('style'); style.id = 'qp-style'; } { style.textContent = ` :root { --xdex-qp-shell-bg: #FFFFEE; --xdex-qp-form-bg: #FFFFEE; --xdex-qp-preview-bg: #F0E0D6; --xdex-qp-textarea-bg: #fff; --xdex-qp-border: #ccc; --xdex-qp-outline: #fff; --xdex-qp-shadow: rgba(0,0,0,.24); --xdex-qp-reset-bg: rgba(0,0,0,.6); --xdex-qp-reset-color: #fff; } :root.xdex-darkreader-active { --xdex-qp-shell-bg: #2b2c2d; --xdex-qp-form-bg: #2b2c2d; --xdex-qp-preview-bg: #483327; --xdex-qp-textarea-bg: #1f2021; --xdex-qp-border: #4b4d50; --xdex-qp-outline: #3a3c3f; --xdex-qp-shadow: rgba(0,0,0,.55); --xdex-qp-reset-bg: rgba(19,20,21,.88); --xdex-qp-reset-color: #e8e6e3; } .qp-overlay { position: fixed; inset: 0; z-index: 9000; background: rgba(0,0,0,.45); display: none; } /* 仅回复浮窗:引用弹窗也用 .qp-stack,不能共用 height/overflow */ .qp-overlay > .qp-stack { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(90vw, 820px); /* 初始宽度为视口的90%,最大820px */ min-width: 0; /* 不设固定值,由 JS 动态控制 */ max-width: none; /* 允许用户手动扩展 */ /* 默认不锁 80vh:由内容贴合,避免表单与预览之间被撑出大空隙 */ height: auto; max-height: min(80vh, calc(100vh - 40px)); overflow: visible; box-sizing: border-box; } /* 仅作用于回复浮窗壳:不要污染引用弹窗的 .qp-quote */ .qp-overlay .qp-stack > .qp-quote { position: absolute; top: 0; left: 0; width: 100%; height: 100%; max-height: 100%; /* 自动贴高默认隐藏滚动条;达上限/手动缩放后再滚动,避免预览空→有时右侧滚动条闪一下 */ overflow-x: hidden; overflow-y: hidden; background: var(--xdex-qp-shell-bg); border: 1px solid var(--xdex-qp-border); outline: 2px solid var(--xdex-qp-outline); border-radius: 8px; box-shadow: 0 8px 24px var(--xdex-qp-shadow); padding: 18px 20px 20px; box-sizing: border-box; display: flex; flex-direction: column; } .qp-overlay > .qp-stack.qp-user-sized > .qp-quote, .qp-overlay > .qp-stack.qp-content-scroll > .qp-quote { overflow-y: auto; } .qp-overlay > .qp-stack.qp-content-scroll > .qp-quote { scrollbar-width: thin; } .qp-overlay > .qp-stack.qp-content-scroll > .qp-quote::-webkit-scrollbar { width: 8px; } .qp-overlay .qp-stack > .qp-quote textarea[name="content"] { resize: vertical !important; /* 允许纵向缩放;宽度跟浮窗走 */ min-width: 0 !important; width: 100% !important; max-width: none; box-sizing: border-box; flex: none !important; /* 不要被 flex 拉高造成假空隙 */ } /* 浮窗内 textarea:允许纵向缩放 */ .qp-body .h-post-form-textarea { resize: vertical !important; min-width: 0; max-width: 100%; box-sizing: border-box; flex: none !important; } /* 四边中段:移动浮窗(角部留给缩放) */ .qp-drag-edge { position: absolute; pointer-events: auto; z-index: 3; cursor: move; } .qp-drag-edge.top { top: 0; left: 14px; right: 14px; height: 10px; } .qp-drag-edge.bottom { bottom: 0; left: 14px; right: 14px; height: 10px; } .qp-drag-edge.left { top: 14px; bottom: 14px; left: 0; width: 10px; } .qp-drag-edge.right { top: 14px; bottom: 14px; right: 0; width: 10px; } /* 四角:拉伸浮窗 */ .qp-resize-corner { position: absolute; width: 14px; height: 14px; z-index: 5; pointer-events: auto; background: transparent; } .qp-resize-corner.nw { top: 0; left: 0; cursor: nwse-resize; } .qp-resize-corner.ne { top: 0; right: 0; cursor: nesw-resize; } .qp-resize-corner.sw { bottom: 0; left: 0; cursor: nesw-resize; } .qp-resize-corner.se { bottom: 0; right: 0; cursor: nwse-resize; } .qp-overlay .qp-quote.is-dragging, .qp-overlay .qp-quote.is-resizing { user-select: none !important; } .qp-overlay .qp-quote.is-dragging { cursor: grabbing !important; } .qp-body { flex: 0 0 auto; /* 贴内容高度,达上限后由 .qp-quote 整体滚动 */ min-height: 0; } /* 归位按钮 */ .qp-reset-btn { position: fixed; right: 12px; bottom: 12px; width: 30px; height: 30px; padding: 0; display: none; align-items: center; justify-content: center; color: var(--xdex-qp-reset-color); background: var(--xdex-qp-reset-bg); border: 1px solid rgba(255,255,255,.35); border-radius: 8px; cursor: pointer; z-index: 9001; /* 比 overlay 高 */ user-select: none; } .qp-body .qp-content-wrap { display: flex; flex-direction: column; gap: 10px; max-width: none; /* 改为 none,不限制最大宽度 */ margin: 0; background: var(--xdex-qp-form-bg); /* 默认贴内容高度;只有手动拉高浮窗时才填满可用空间 */ flex: 0 0 auto; min-height: 0; width: 100%; height: auto; overflow: visible; /* 不裁剪内容;超长时由回复浮窗 .qp-quote 整体滚动 */ } .qp-body .qp-content-wrap form { max-width: 100%; box-sizing: border-box; background: var(--xdex-qp-form-bg); display: block; /* 保持站点原生表单流,避免 flex 把内部行拉开 */ flex: 0 0 auto; /* 表单按内容高度,不吃掉空隙、也不被压扁 */ min-height: auto; order: 0; position: relative; z-index: 2; /* 缩小空间时表单盖在预览上方,避免被盖住 */ } /* textarea 宽度跟浮窗走,允许纵向缩放;高度由内容/用户拖拽决定,不 flex 撑满 */ .qp-body .qp-content-wrap textarea[name="content"] { resize: vertical !important; min-width: 0 !important; min-height: 120px; width: 100% !important; max-width: none; flex: none !important; box-sizing: border-box; background: var(--xdex-qp-textarea-bg); } .qp-body .qp-content-wrap .h-preview-box { width: 100% !important; /* 始终和容器一致 */ overflow-wrap: break-word; /* 长单词/长链接换行 */ word-break: break-word; /* 兼容性处理 */ white-space: normal; /* 允许正常换行 */ /* 关键:预览默认不 flex 撑满,否则会在表单下方制造大空隙 */ flex: 0 1 auto; min-height: 0; max-height: none; overflow: visible; /* 预览不单独滚动;超长时由回复浮窗整体滚动 */ order: 1; position: relative; z-index: 1; } /* 用户手动拉高浮窗后:允许 wrap/预览吃掉剩余高度 */ /* 用户手动拉高:仅让表单区保持自然高度;内容超高时仍走整窗滚动 */ .qp-overlay > .qp-stack.qp-user-sized .qp-content-wrap { flex: 0 0 auto; height: auto; } .qp-overlay > .qp-stack.qp-user-sized .qp-content-wrap .h-preview-box { flex: 0 0 auto; overflow: visible; } /* 兜底:引用弹窗保持内容自适应,不被回复浮窗规则压扁 */ .qp-overlay-quote .qp-quote { height: auto !important; max-height: 100% !important; overflow: auto !important; display: block !important; } /* 浮窗内预览框及其子层级全部占满宽度 */ .qp-body .qp-content-wrap .h-preview-box, .qp-body .qp-content-wrap .h-preview-box .h-threads-item, .qp-body .qp-content-wrap .h-preview-box .h-threads-item-replies, .qp-body .qp-content-wrap .h-preview-box .h-threads-item-reply, .qp-body .qp-content-wrap .h-preview-box .h-threads-item-reply-main { width: 100% !important; max-width: none !important; margin: 0 !important; box-sizing: border-box; display: block; background: var(--xdex-qp-preview-bg); } /* 浮窗内:仅在非 h-active 状态下限制缩略图宽度 */ .qp-body .qp-content-wrap .h-preview-box .h-threads-img-box:not(.h-active) .h-threads-img { max-width: 33% !important; height: auto !important; } /* 表单内:仅在非 h-active 状态下限制缩略图宽度 */ #h-post-form .h-preview-box .h-threads-img-box:not(.h-active) .h-threads-img { max-width: 50% !important; height: auto !important; } /* 扩展坞(与 ensureRightSidebarShellStyle 保持一致的最新版本,兜底双份冗余) */ #h-tool { display: none !important; } .hld__docker { position: fixed; height: 80px; width: 30px; bottom: 180px; right: 0; transition: width .12s ease, height .12s ease, bottom .12s ease; z-index: 9998; } .hld__docker:hover, .hld__docker.is-hover { width: 150px; height: 300px; bottom: 75px; } .hld__docker:has(.hld__docker-sidebar:hover) { width: 150px; height: 300px; bottom: 75px; } .hld__docker.xdex-docker-boot, .hld__docker.xdex-docker-boot:hover, .hld__docker.xdex-docker-boot.is-hover, .hld__docker.xdex-docker-boot:has(.hld__docker-sidebar:hover) { transition: none; } .hld__docker-sidebar { background: #fff; position: fixed; height: 50px; width: 20px; bottom: 195px; right: 0; display: flex; justify-content: center; align-items: center; border: 1px solid #CCC; box-shadow: 0 0 1px #333; border-right: none; border-radius: 5px 0 0 5px; } .hld__docker-btns { position: absolute; top: 0; left: 50px; bottom: 0; right: 50px; display: flex; justify-content: center; align-items: center; flex-direction: column; } .hld__docker .hld__docker-btns>div { opacity: 0; flex-shrink: 0; pointer-events: none; transition: opacity .06s ease; } .hld__docker:hover .hld__docker-btns>div, .hld__docker.is-hover .hld__docker-btns>div, .hld__docker:has(.hld__docker-sidebar:hover) .hld__docker-btns>div { opacity: 1; pointer-events: auto; } .hld__docker.xdex-docker-boot .hld__docker-btns>div { transition: none; } .hld__docker-btns>div { background: #fff; border: 1px solid #CCC; box-shadow: 0 0 1px #444; width: 50px; height: 50px; border-radius: 50%; margin: 7px 0; cursor: pointer; display: flex; justify-content: center; align-items: center; font-size: 20px; font-weight: bold; color: #333; transition: background .12s ease, transform .12s ease, opacity .06s ease; } .hld__docker-btns>div:hover { background: #f0f0f0; transform: scale(1.1); } .hld__docker-btns>div svg { display: block; } .hld__docker-btns>div svg path { fill: currentColor; } .hld__docker-btns>div[data-type="PREV-THREAD"], .hld__docker-btns>div[data-type="NEXT-THREAD"], .hld__docker-btns>div[data-type="TOP"], .hld__docker-btns>div[data-type="BOTTOM"] { width: 41px; height: 41px; font-size: 16px; } /* 固定模式:dock 收窄贴右、三按钮常显、隐藏左侧把手(不依赖 hover/:has,回退机制不受影响) */ .hld__docker.xdex-dock-fixed { width: 60px; height: 300px; bottom: 75px; } .hld__docker.xdex-dock-fixed:hover, .hld__docker.xdex-dock-fixed.is-hover, .hld__docker.xdex-dock-fixed:has(.hld__docker-sidebar:hover) { width: 60px; height: 300px; bottom: 75px; } .hld__docker.xdex-dock-fixed .hld__docker-sidebar { display: none; } .hld__docker.xdex-dock-fixed .hld__docker-btns { left: 0; right: 0; } .hld__docker.xdex-dock-fixed .hld__docker-btns>div { opacity: 1; pointer-events: auto; } .hld__docker.xdex-dock-boot.xdex-dock-fixed { transition: none; } `; if (!style.parentNode) { (document.head || document.documentElement).appendChild(style); } } ensureDarkReaderThemeSync(); return !!document.getElementById('qp-style'); } function ensureRightSidebarReplyController() { if (window.__xdexRightSidebarReplyController) return window.__xdexRightSidebarReplyController; // 悬浮窗引用 let overlay; function ensureOverlay() { if (!overlay) { overlay = document.createElement('div'); overlay.className = 'qp-overlay'; overlay.innerHTML = ` <div class="qp-stack"> <div class="qp-quote"> <div class="qp-drag-edge top"></div> <div class="qp-drag-edge bottom"></div> <div class="qp-drag-edge left"></div> <div class="qp-drag-edge right"></div> <div class="qp-resize-corner nw" data-dir="nw"></div> <div class="qp-resize-corner ne" data-dir="ne"></div> <div class="qp-reset-btn xdex-icon-btn" title="回复浮窗归位"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path style="fill:none" d="M18 18A8.5 8.5 0 1 1 18.5 6.5"/><path style="fill:none" d="M18.5 6.5l-1.3 3.6"/><path style="fill:none" d="M19.2 10.6L18.5 6.5l-3.2 2.7"/></svg></div> <div class="qp-resize-corner se" data-dir="se"></div> <div class="qp-body"></div> </div> </div> <div class="qp-reset-btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path style="fill:none" d="M18 18A8.5 8.5 0 1 1 18.5 6.5"/><path style="fill:none" d="M18.5 6.5l-1.3 3.6"/><path style="fill:none" d="M19.2 10.6L18.5 6.5l-3.2 2.7"/></svg></div> `; document.body.appendChild(overlay); // 点击遮罩关闭(点内容不关闭,且允许事件冒泡到 document 以触发引用弹窗) overlay.addEventListener('click', function (e) { // 冒泡途中若有控件替换了自身内容(如 .js-extra 切换图标),e.target 会脱离文档, // closest('.qp-quote') 随即失效、被误判成「点了遮罩」;改用派发时定格的 composedPath 判定 const evPath = typeof e.composedPath === 'function' ? e.composedPath() : null; const inQuote = evPath ? evPath.some(el => el && el.classList && el.classList.contains('qp-quote')) : !!(e.target && e.target.closest && e.target.closest('.qp-quote')); if (inQuote) { return; // 点击在内容区:不关闭,也不阻止冒泡 } // 如果正在调整 textarea 大小,不关闭浮窗 if (overlay.__isResizing) { return; } closeOverlay(); }); // ESC 关闭(优先关闭颜文字选择框) document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; // 检查是否存在打开的颜文字面板 const openKaomojiPanel = document.querySelector('.kaomoji-panel[style*="display: grid"]'); if (openKaomojiPanel) { // 触发颜文字函数内的关闭逻辑(模拟点击空白或ESC) openKaomojiPanel.style.display = 'none'; // 若有全局绑定的 hidePanel 函数(例如触发按钮附近定义的),触发它 const hideEvent = new CustomEvent('kaomoji:hide'); document.dispatchEvent(hideEvent); //openKaomojiPanel.style.display = 'none'; // ★ 新增:关闭颜文字面板后聚焦到浮窗内的 textarea const ta = overlay?.querySelector('textarea[name="content"]'); if (ta) { ta.focus(); } return; // 阻止继续关闭浮窗 } // 若没有颜文字面板打开,则关闭回复浮窗 closeOverlay(); }); // 归位按钮事件:恢复默认居中尺寸(并重新贴合内容) const resetBtn = overlay.querySelector('.qp-reset-btn'); resetBtn.addEventListener('click', (e) => { e.stopPropagation(); // 阻止事件冒泡,避免触发 overlay 的关闭逻辑 overlay.__savedPanelRect = null; overlay.__userSizedPanel = false; applyReplyPanelLayout(overlay, null); scheduleReplyPanelAutoFit(overlay); }); } return overlay; } function closeOverlay() { if (!overlay) return; // 清理 ResizeObserver(旧链路兜底) if (overlay.__resizeObserver) { overlay.__resizeObserver.disconnect(); overlay.__resizeObserver = null; } // 清理内容贴合观察 if (overlay.__autoFitRO) { try { overlay.__autoFitRO.disconnect(); } catch (_) {} overlay.__autoFitRO = null; } if (overlay.__autoFitRaf) { cancelAnimationFrame(overlay.__autoFitRaf); overlay.__autoFitRaf = 0; } // 页面内记住整窗位置/尺寸(不写 GM_setValue) // 仅手动四角拉伸过的尺寸需要保留;内容自然撑高的高度关闭后丢弃,下次快速回空高 if (overlay.__userSizedPanel) { saveReplyPanelRect(overlay); } else { const stack = overlay.querySelector('.qp-stack'); if (stack) { const rect = stack.getBoundingClientRect(); // 只记位置/宽度,高度不记,避免被预览撑开的高度“粘住” overlay.__savedPanelRect = { winW: Math.round(overlay.__panelWinW || window.innerWidth), // 布局时窗口宽度: 打开期间 resize 后关闭也能在下次重开时重新居中 width: Math.round(rect.width), left: Math.round(rect.left), top: Math.round(rect.top), userSized: false // height 故意不写:open 时走默认空高 + 快速 autofit }; } else { overlay.__savedPanelRect = null; } overlay.__userSizedPanel = false; } overlay.style.display = 'none'; // 隐藏归位按钮 const resetBtn = overlay.querySelector('.qp-reset-btn'); if (resetBtn) resetBtn.style.display = 'none'; // 清理窗口 resize 监听(旧链路兜底) if (overlay.__windowResizeHandler) { window.removeEventListener('resize', overlay.__windowResizeHandler); overlay.__windowResizeHandler = null; } if (overlay.__formEl && overlay.__formEl.__placeholder) { overlay.__formEl.__placeholder.parentNode.insertBefore(overlay.__formEl, overlay.__formEl.__placeholder); // 移回后清除 textarea 的宽度样式,让它恢复原始状态 const taInForm = overlay.__formEl.querySelector('textarea[name="content"]'); if (taInForm) { taInForm.style.removeProperty('width'); taInForm.style.removeProperty('height'); taInForm.style.removeProperty('min-width'); taInForm.style.removeProperty('max-width'); } if (overlay.__formEl.__wasCollapsed) { const formEl = overlay.__formEl; const $form = $(formEl); const hint = formEl.action.includes('doReplyThread') ? '『回复』' : '『发串』'; // 优先还原 early collapse 留下的折叠按钮;否则走通用 collapse const toggleEl = formEl.__collapseToggleEl || ($form.prev('.xdex-placeholder.xdex-generic-toggle')[0] || null); if (toggleEl && toggleEl.parentNode) { formEl.classList.add('xdex-generic-collapsed'); $form.hide().data('xdex-collapsed', true); toggleEl.style.display = ''; toggleEl.innerHTML = `${hint}(点击展开)`; } else if ($form.prev('.xdex-placeholder').length) { $form.hide().data('xdex-collapsed', true); } else if (typeof Utils !== 'undefined' && typeof Utils.collapse === 'function') { Utils.collapse($form, hint); } formEl.__wasCollapsed = false; formEl.__collapseToggleEl = null; } } if (overlay.__previewEl && overlay.__previewEl.__placeholder) { overlay.__previewEl.__placeholder.parentNode.insertBefore(overlay.__previewEl, overlay.__previewEl.__placeholder); } } // 回复浮窗:整窗拖拽 + 四角缩放(页面内记忆,不写 GM_setValue) const REPLY_PANEL_MIN_WIDTH = 400; const REPLY_PANEL_MIN_HEIGHT = 280; const REPLY_PANEL_MAX_VH = 0.8; // 默认垂直位置:0.5=正中,越小越靠上。0.28 ≈ 偏上约 1/3 const REPLY_PANEL_DEFAULT_TOP_RATIO = 0.28; function getReplyPanelMaxHeight() { return Math.max(REPLY_PANEL_MIN_HEIGHT, Math.min(window.innerHeight * REPLY_PANEL_MAX_VH, window.innerHeight - 40)); } function getReplyPanelDefaultTop(height) { const h = Math.max(REPLY_PANEL_MIN_HEIGHT, Number(height) || REPLY_PANEL_MIN_HEIGHT); // 比垂直居中更靠上,同时保证不贴顶、底部也留一点余量 const raw = Math.round((window.innerHeight - h) * REPLY_PANEL_DEFAULT_TOP_RATIO); return Math.max(8, Math.min(raw, window.innerHeight - h - 8)); } function getReplyPanelDefaults() { const width = Math.max(REPLY_PANEL_MIN_WIDTH, Math.min(window.innerWidth * 0.9, 820)); // 默认高度不再固定 80vh,只给一个贴合内容的保守初值;open 后会 auto-fit // 0.45 仍然偏高,会在表单/预览间留空;用更贴内容的初值 const height = Math.max(REPLY_PANEL_MIN_HEIGHT, Math.min(Math.round(window.innerHeight * 0.32), getReplyPanelMaxHeight())); const left = Math.max(0, Math.round((window.innerWidth - width) / 2)); const top = getReplyPanelDefaultTop(height); return { width, height, left, top }; } function measureReplyPanelContentHeight(ov) { const quote = ov && ov.querySelector('.qp-quote'); const wrap = ov && ov.querySelector('.qp-content-wrap'); if (!quote || !wrap) return REPLY_PANEL_MIN_HEIGHT; const cs = window.getComputedStyle(quote); const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0) + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0); const form = wrap.querySelector('form'); const preview = wrap.querySelector('.h-preview-box'); const gap = 10; // 与 .qp-content-wrap 的 gap 对齐 let contentH = 0; if (form) contentH += form.offsetHeight || form.getBoundingClientRect().height || 0; if (preview) { // 预览在 flex/overflow 约束下 offsetHeight 可能是被裁后的值,临时放开量真实内容高 const prev = { maxHeight: preview.style.maxHeight, height: preview.style.height, overflow: preview.style.overflow, flex: preview.style.flex }; preview.style.maxHeight = 'none'; preview.style.height = 'auto'; preview.style.overflow = 'visible'; preview.style.flex = 'none'; contentH += preview.scrollHeight || preview.offsetHeight || 0; preview.style.maxHeight = prev.maxHeight; preview.style.height = prev.height; preview.style.overflow = prev.overflow; preview.style.flex = prev.flex; } else { // 无预览时仍量 wrap 自身 contentH = Math.max(contentH, wrap.scrollHeight || wrap.offsetHeight || 0); } if (form && preview) contentH += gap; return Math.max(REPLY_PANEL_MIN_HEIGHT, Math.ceil(contentH + padY + 2)); } function fitReplyPanelToContent(ov, opts) { if (!ov || ov.__userSizedPanel || ov.__autoFitting) return; const stack = ov.querySelector('.qp-stack'); if (!stack || ov.style.display === 'none') return; const options = opts || {}; // 量高会临时改 preview 样式,期间屏蔽 RO,避免“量高→RO→再 fit”反馈环 ov.__autoFitting = true; try { const maxH = getReplyPanelMaxHeight(); const needed = Math.min(measureReplyPanelContentHeight(ov), maxH); const rect = stack.getBoundingClientRect(); // 高度几乎不变时不写 style;no-op 时不要开静默窗,否则会吞掉紧随其后的预览增高 const heightDelta = Math.abs((rect.height || 0) - needed); // 滞回判定: 进入/退出滚动态需跨越阈值带, 避免内容高度临界抖动导致滚动条反复出现/消失、横向挤压布局 const wasScroll = stack.classList.contains('qp-content-scroll'); const atCap = wasScroll ? (needed > maxH - 24) : (needed >= maxH - 1); stack.classList.toggle('qp-content-scroll', atCap); if (heightDelta < 2) { if (options.save !== false) { ov.__savedPanelRect = { winW: Math.round(ov.__panelWinW || window.innerWidth), width: Math.round(rect.width), height: Math.round(rect.height), left: Math.round(rect.left), top: Math.round(rect.top), userSized: false }; } ov.__skipAutoFitQuiet = true; return; } let left = rect.left; let top = rect.top; let width = rect.width || getReplyPanelDefaults().width; // 未手动缩放时:优先向下扩展(顶边不动),避免“预览往上挤表单” if (!Number.isFinite(left)) left = Math.max(0, Math.round((window.innerWidth - width) / 2)); if (!Number.isFinite(top) || top < 0) top = getReplyPanelDefaultTop(needed); // 只有底部真的装不下时才上移;否则保持 top,让高度向下长 if (top + needed > window.innerHeight - 8) { top = Math.max(8, window.innerHeight - needed - 8); } // 宽度无效时回退默认 if (!width || width < REPLY_PANEL_MIN_WIDTH) width = getReplyPanelDefaults().width; applyReplyPanelLayout(ov, { width, height: needed, left, top }, { preserveUserSized: true, fromAutoFit: true }); if (options.save !== false) { // auto-fit 只记当前几何,不把 userSized 标真 const r = stack.getBoundingClientRect(); ov.__savedPanelRect = { winW: Math.round(ov.__panelWinW || window.innerWidth), width: Math.round(r.width), height: Math.round(r.height), left: Math.round(r.left), top: Math.round(r.top), userSized: false }; } } finally { ov.__autoFitting = false; // 仅在真正改了尺寸时短时静默;no-op 贴高不能静默,否则挡住预览写完后的二次贴高 if (ov.__skipAutoFitQuiet) { ov.__skipAutoFitQuiet = false; } else { ov.__autoFitQuietUntil = performance.now() + 48; } } } function scheduleReplyPanelAutoFit(ov) { if (!ov || ov.__userSizedPanel || ov.__autoFitting) return; if (ov.__autoFitQuietUntil && performance.now() < ov.__autoFitQuietUntil) return; if (ov.__autoFitRaf) cancelAnimationFrame(ov.__autoFitRaf); // 双 rAF:等布局/预览重建完成后再量高,合并同一帧内的多次 input/RO ov.__autoFitRaf = requestAnimationFrame(() => { ov.__autoFitRaf = requestAnimationFrame(() => { ov.__autoFitRaf = 0; if (ov.__userSizedPanel || ov.__autoFitting) return; if (ov.__autoFitQuietUntil && performance.now() < ov.__autoFitQuietUntil) return; fitReplyPanelToContent(ov); }); }); } function ensureReplyPanelAutoFitObserver(ov) { if (!ov) return; if (ov.__autoFitRO) { try { ov.__autoFitRO.disconnect(); } catch (_) {} ov.__autoFitRO = null; } const wrap = ov.querySelector('.qp-content-wrap'); const ta = ov.querySelector('textarea[name="content"]'); if (!wrap || typeof ResizeObserver === 'undefined') return; const onMaybeAutoFit = () => { if (ov.__userSizedPanel || ov.__autoFitting) return; if (ov.__autoFitQuietUntil && performance.now() < ov.__autoFitQuietUntil) return; scheduleReplyPanelAutoFit(ov); }; const ro = new ResizeObserver(onMaybeAutoFit); // 只观察 wrap/preview。不要监听 textarea input: // input 时预览尚未更新,会先 no-op 贴高并制造滚动条闪动窗口。 ro.observe(wrap); // 预览内容变化(首次出字/粘贴图/引用展开)也跟 const preview = ov.querySelector('.h-preview-box'); if (preview) ro.observe(preview); ov.__autoFitRO = ro; // 暴露给 renderContent:预览写完后立刻排队贴高 window.__xdexScheduleReplyPanelFit = () => scheduleReplyPanelAutoFit(ov); } function applyReplyPanelLayout(ov, rect, opts) { const stack = ov && ov.querySelector('.qp-stack'); const quote = ov && ov.querySelector('.qp-quote'); if (!stack) return; const options = opts || {}; const base = getReplyPanelDefaults(); const r = rect && typeof rect === 'object' ? rect : base; let width = Math.max(REPLY_PANEL_MIN_WIDTH, Number(r.width) || base.width); let height = Math.max(REPLY_PANEL_MIN_HEIGHT, Number(r.height) || base.height); width = Math.min(width, window.innerWidth); // 未手动缩放时,高度上限 80vh;手动缩放后允许到视口高 const maxH = ov && ov.__userSizedPanel ? window.innerHeight : getReplyPanelMaxHeight(); height = Math.min(height, maxH); let left = Number.isFinite(Number(r.left)) ? Number(r.left) : base.left; let top = Number.isFinite(Number(r.top)) ? Number(r.top) : base.top; // 允许略微拖出视口,但至少保留 50px 可抓取 left = Math.min(Math.max(left, -width + 50), window.innerWidth - 50); top = Math.min(Math.max(top, -height + 50), window.innerHeight - 50); stack.style.position = 'fixed'; stack.style.transform = 'none'; stack.style.width = width + 'px'; stack.style.height = height + 'px'; stack.style.left = left + 'px'; stack.style.top = top + 'px'; stack.style.maxWidth = 'none'; stack.style.minWidth = REPLY_PANEL_MIN_WIDTH + 'px'; stack.style.maxHeight = maxH + 'px'; // 用户手动尺寸时允许内部 flex 填满;自动贴合时保持内容高度,避免预览被拉出空隙 if (ov.__userSizedPanel) stack.classList.add('qp-user-sized'); else stack.classList.remove('qp-user-sized'); if (quote) { quote.style.top = '0px'; quote.style.left = '0px'; quote.style.width = '100%'; quote.style.height = '100%'; } if (!options.preserveUserSized && r && r.userSized) { ov.__userSizedPanel = true; stack.classList.add('qp-user-sized'); } } function saveReplyPanelRect(ov) { const stack = ov && ov.querySelector('.qp-stack'); if (!stack || !ov) return; const rect = stack.getBoundingClientRect(); ov.__savedPanelRect = { winW: Math.round(window.innerWidth), width: Math.round(rect.width), height: Math.round(rect.height), left: Math.round(rect.left), top: Math.round(rect.top), userSized: !!ov.__userSizedPanel }; } function bindReplyPanelFrame(ov) { if (!ov || ov.__replyPanelFrameBound) return; const stack = ov.querySelector('.qp-stack'); const quote = ov.querySelector('.qp-quote'); if (!stack || !quote) return; ov.__replyPanelFrameBound = true; const onEdgeDown = (e) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startY = e.clientY; const startRect = stack.getBoundingClientRect(); quote.classList.add('is-dragging'); ov.__isResizing = true; // 避免拖拽结束瞬间点到遮罩关闭 const onMove = (ev) => { ev.preventDefault(); const dx = ev.clientX - startX; const dy = ev.clientY - startY; let left = startRect.left + dx; let top = startRect.top + dy; left = Math.min(Math.max(left, -startRect.width + 50), window.innerWidth - 50); top = Math.min(Math.max(top, -startRect.height + 50), window.innerHeight - 50); stack.style.transform = 'none'; stack.style.left = left + 'px'; stack.style.top = top + 'px'; }; const onUp = () => { quote.classList.remove('is-dragging'); setTimeout(() => { ov.__isResizing = false; }, 100); saveReplyPanelRect(ov); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }; const onCornerDown = (e) => { if (e.button !== 0) return; const dir = e.currentTarget.getAttribute('data-dir') || ''; if (!dir) return; e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startY = e.clientY; const startRect = stack.getBoundingClientRect(); // 对角锚定:拖动某角时,对侧角位置保持不动 const anchorRight = startRect.right; const anchorBottom = startRect.bottom; quote.classList.add('is-resizing'); ov.__isResizing = true; // 用户一旦四角拉伸,就关闭内容自动贴合高度 ov.__userSizedPanel = true; const onMove = (ev) => { ev.preventDefault(); const dx = ev.clientX - startX; const dy = ev.clientY - startY; let left = startRect.left; let top = startRect.top; let width = startRect.width; let height = startRect.height; if (dir.indexOf('e') !== -1) { width = startRect.width + dx; } if (dir.indexOf('s') !== -1) { height = startRect.height + dy; } if (dir.indexOf('w') !== -1) { width = startRect.width - dx; left = anchorRight - width; } if (dir.indexOf('n') !== -1) { height = startRect.height - dy; top = anchorBottom - height; } if (width < REPLY_PANEL_MIN_WIDTH) { if (dir.indexOf('w') !== -1) left = anchorRight - REPLY_PANEL_MIN_WIDTH; width = REPLY_PANEL_MIN_WIDTH; } if (height < REPLY_PANEL_MIN_HEIGHT) { if (dir.indexOf('n') !== -1) top = anchorBottom - REPLY_PANEL_MIN_HEIGHT; height = REPLY_PANEL_MIN_HEIGHT; } if (width > window.innerWidth) width = window.innerWidth; if (height > window.innerHeight) height = window.innerHeight; stack.style.transform = 'none'; stack.style.left = left + 'px'; stack.style.top = top + 'px'; stack.style.width = width + 'px'; stack.style.height = height + 'px'; stack.style.maxHeight = window.innerHeight + 'px'; }; const onUp = () => { quote.classList.remove('is-resizing'); setTimeout(() => { ov.__isResizing = false; }, 100); saveReplyPanelRect(ov); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }; quote.querySelectorAll('.qp-drag-edge').forEach((el) => { el.addEventListener('mousedown', onEdgeDown); }); quote.querySelectorAll('.qp-resize-corner').forEach((el) => { el.addEventListener('mousedown', onCornerDown); }); } // REPLY 按钮 function open() { let formEl = document.querySelector('form[action="/Home/Forum/doReplyThread.html"]'); let previewEl = document.querySelector('.h-preview-box'); // 如果串内没找到表单,尝试板块页发串表单 if (!formEl) { const postForm = document.querySelector('#h-post-form form[action="/Home/Forum/doPostThread.html"]'); if (postForm) { formEl = postForm; previewEl = document.querySelector('#h-post-form .h-preview-box'); } } if (!formEl) { toast && toast('未找到回复/发串表单'); return; } // early open 也必须先注入浮窗样式/主题,避免表单先以无样式状态闪一下 // batch2 可能已把 qp-style 延后到 idle;此处是悬停优化后的同步兜底 ensureReplyOverlayStyle(); try { ensureDarkReaderThemeObserver(); } catch (e) {} const ov = ensureOverlay(); const body = ov.querySelector('.qp-body'); body.innerHTML = ''; // 占位符 if (!formEl.__placeholder) { const ph1 = document.createElement('div'); ph1.style.display = 'none'; formEl.parentNode.insertBefore(ph1, formEl); formEl.__placeholder = ph1; } if (previewEl && !previewEl.__placeholder) { const ph2 = document.createElement('div'); ph2.style.display = 'none'; previewEl.parentNode.insertBefore(ph2, previewEl); previewEl.__placeholder = ph2; } // 折叠表单先只记状态,不要在原位 show()——否则 early open 时表单会先裸奔到页面上。 // 真正展开放到 body.appendChild(wrap) 之后、overlay 显示之前。 // 同时处理 Utils.collapse 留下的可见折叠按钮,避免它留在原位/被误搬进浮窗。 const $formEl = $(formEl); const wasCollapsedForOpen = !!$formEl.data('xdex-collapsed') || formEl.classList.contains('xdex-generic-collapsed'); if (wasCollapsedForOpen) { formEl.__wasCollapsed = true; // ★ 记录原本是折叠的 let collapseToggleEl = null; const prev = formEl.previousElementSibling; if (prev && prev.classList.contains('xdex-placeholder') && prev.classList.contains('xdex-generic-toggle')) { collapseToggleEl = prev; } else { const $ph = $formEl.prev('.xdex-placeholder.xdex-generic-toggle'); if ($ph.length) collapseToggleEl = $ph[0]; } if (collapseToggleEl) { collapseToggleEl.style.display = 'none'; formEl.__collapseToggleEl = collapseToggleEl; } $formEl.removeData('xdex-collapsed'); formEl.classList.remove('xdex-generic-collapsed'); } // === 新增:给表单打标记,避免被板块页展开逻辑 / early collapse 误处理 === formEl.classList.add('qp-reply-form'); // 包装容器,防止 UI 松散 const wrap = document.createElement('div'); wrap.className = 'qp-content-wrap'; // 表单是必需的 wrap.appendChild(formEl); // 预览框是可选的 if (previewEl) { wrap.appendChild(previewEl); // 浮窗内:强制预览框及其 replies 拉满宽度,清理站点样式的限宽/居中 if (previewEl) { // 预览框本身满宽 previewEl.style.width = '100%'; previewEl.style.maxWidth = 'none'; previewEl.style.margin = '0'; // replies 拉满 const replies = previewEl.querySelectorAll('.h-threads-item-replies'); replies.forEach(el => { el.style.width = '100%'; el.style.maxWidth = 'none'; el.style.margin = '0'; el.style.display = 'block'; el.style.boxSizing = 'border-box'; }); // 如有外层 item/容器被限宽,一并放开 const items = previewEl.querySelectorAll('.h-threads-item'); items.forEach(el => { el.style.width = '100%'; el.style.maxWidth = 'none'; el.style.margin = '0'; el.style.boxSizing = 'border-box'; }); } } body.appendChild(wrap); // 表单已进浮窗后再展开,避免原位闪现/拆碎漂在左上角; // 同时确保 early collapse 的折叠按钮不会出现在浮窗里。 if (wasCollapsedForOpen) { $(formEl).show(); formEl.classList.remove('xdex-generic-collapsed'); if (formEl.__collapseToggleEl && formEl.__collapseToggleEl.parentNode) { // 折叠按钮留在原位隐藏即可;不要带进浮窗 formEl.__collapseToggleEl.style.display = 'none'; } // 只清理整表单『回复/发串』折叠按钮;不要删 form 内的「可选项」按钮 if (formEl.__collapseToggleEl && wrap.contains(formEl.__collapseToggleEl)) { formEl.__collapseToggleEl.remove(); formEl.__collapseToggleEl = null; } } // early open:ready/batch2 可能还没跑 enhancePostFormLayout, // 导致浮窗内没有“可选项”(标题/名称/E-mail)折叠按钮、送出按钮也还在标题行。 // 这里对当前 form 同步补做一次(函数内部已幂等)。 try { if (typeof enhancePostFormLayout === 'function') enhancePostFormLayout(formEl); } catch (e) {} // early open:若预览框尚未创建,立刻按当前 form 补一个 skeleton, // 避免用户先看到无预览表单,再等 enhanceIsland/batch2。 if (!previewEl || !previewEl.isConnected || !wrap.contains(previewEl)) { try { if (typeof tryInsertPreviewBoxEarly === 'function') tryInsertPreviewBoxEarly(formEl); } catch (e) {} // 优先取紧邻当前 form 的预览,再兜底全局 previewEl = (formEl.nextElementSibling && formEl.nextElementSibling.classList?.contains('h-preview-box')) ? formEl.nextElementSibling : (wrap.querySelector('.h-preview-box') || document.querySelector('.qp-body .h-preview-box') || document.querySelector('.h-preview-box')); if (previewEl && !wrap.contains(previewEl)) { if (!previewEl.__placeholder) { const ph2 = document.createElement('div'); ph2.style.display = 'none'; if (previewEl.parentNode) previewEl.parentNode.insertBefore(ph2, previewEl); previewEl.__placeholder = ph2; } previewEl.style.width = '100%'; previewEl.style.maxWidth = 'none'; previewEl.style.margin = '0'; wrap.appendChild(previewEl); } } // 确保粘贴图片预览已绑定(幂等),即使 enhanceIsland 尚未初始化, // 早期打开浮窗时粘贴图片也能被捕获并更新到当前活跃预览框。 if (typeof bindPasteImagePreviewOnce === 'function') bindPasteImagePreviewOnce(); // === 全面修复:搬运“滞后出现的预览框”进浮窗 === // 若 open() 执行时页面还没有 .h-preview-box(enhanceIsland 尚未初始化), // 之后 enhanceIsland 会把新的预览框插到浮窗外(原位置)。如果此时浮窗里 // 还没有预览框,就把浮窗外的那个搬进来,否则粘贴图片的预览会更新到 // 用户看不见的框。短时重试几次,覆盖 batch2 稍晚创建的情况。 const adoptStrayPreviewIntoPanel = () => { const bodyNow = ov.querySelector('.qp-body'); if (!bodyNow) return false; const wrapNow = bodyNow.querySelector('.qp-content-wrap') || wrap; if (bodyNow.querySelector('.h-preview-box')) { // 浮窗内已有:仍补一次表单布局(幂等),防止只补了预览漏了可选项 try { if (typeof enhancePostFormLayout === 'function') enhancePostFormLayout(formEl); } catch (e) {} return true; } const stray = document.querySelector('.h-preview-box'); if (!stray || stray.closest('.qp-body')) return false; // 没有,或已在别的浮窗 // 记录占位符(与 closeOverlay 的还原逻辑对应) if (!stray.__placeholder) { const ph = document.createElement('div'); ph.style.display = 'none'; if (stray.parentNode) stray.parentNode.insertBefore(ph, stray); stray.__placeholder = ph; } stray.style.width = '100%'; stray.style.maxWidth = 'none'; stray.style.margin = '0'; // 放进同一个 content wrap,避免再叠一个 wrap 导致布局错乱 wrapNow.appendChild(stray); ov.__previewEl = stray; if (typeof extendQuote === 'function') extendQuote(stray); if (typeof initContent === 'function') { try { initContent(stray); } catch (e) {} } // 搬运完成后,如果 file input 里已有图片(用户在 enhanceIsland 之前就粘贴了), // 立即补渲染到刚搬进浮窗的预览框,避免“图片已插入但预览不显示”。 const movedInput = wrapNow.querySelector('input[type="file"][name="image"]') || document.querySelector('.qp-body input[type="file"][name="image"]'); if (movedInput && movedInput.files && movedInput.files[0]) { updatePreviewImageFromFile(movedInput.files[0]); } // 滞后预览框搬入后:立刻贴合,避免用户先看到只有表单的矮窗再被撑高 ensureReplyPanelAutoFitObserver(ov); if (!ov.__userSizedPanel) fitReplyPanelToContent(ov, { save: true }); scheduleReplyPanelAutoFit(ov); return true; }; // 立刻试一次 + 短时重试(enhanceIsland 在 batch2,可能比 open 晚几十~几百 ms) if (!adoptStrayPreviewIntoPanel()) { let tries = 0; const timer = setInterval(() => { tries += 1; if (adoptStrayPreviewIntoPanel() || tries >= 12) clearInterval(timer); }, 50); } // 浮窗内立即处理扩展引用,保证可点击引用弹窗 if (typeof extendQuote === 'function') { extendQuote(previewEl || wrap); } if (typeof initContent === 'function') { try { initContent(previewEl || wrap); } catch (e) {} } // if (typeof autoHideRefView === 'function') { // try { autoHideRefView(root); } catch (e) { try { autoHideRefView(); } catch (e) {} } // } // 保存引用 ov.__formEl = formEl; ov.__previewEl = previewEl; // 显示 overlay 前再同步一次主题,确保 darkreader 配色首帧就位 if (typeof syncQuotePopupTheme === 'function') syncQuotePopupTheme(); // 恢复/应用整窗布局,并绑定四边移动 + 四角缩放 // 若上次是用户四角拉伸过,继续尊重其尺寸; // 非手动尺寸:先隐藏测量真实内容高,再露出,避免“先空高再撑高”的跳变 let layoutRect = null; if (ov.__savedPanelRect && ov.__savedPanelRect.userSized) { ov.__userSizedPanel = true; layoutRect = ov.__savedPanelRect; } else { ov.__userSizedPanel = false; const saved = ov.__savedPanelRect || null; const base = getReplyPanelDefaults(); // 记录本次布局计算时的视口宽度(而非关闭时刻的实时宽度): // 打开期间改变窗口宽度后关闭, 下次打开应重新居中而不是沿用旧几何 ov.__panelWinW = Math.round(window.innerWidth); // 窗口宽度已变化: 上次保存的水平几何(位置/宽度)不可信, 按当前视口重新居中; // 垂直位置沿用上次(fit 阶段有越界上移兜底) const viewportWidthChanged = saved && Number.isFinite(Number(saved.winW)) && Math.abs(Number(saved.winW) - window.innerWidth) > 2; const useSavedW = saved && saved.width && !viewportWidthChanged; const useSavedLeft = saved && Number.isFinite(Number(saved.left)) && !viewportWidthChanged; const useSavedTop = saved && Number.isFinite(Number(saved.top)); layoutRect = { width: useSavedW ? saved.width : base.width, height: base.height, // 仅作测量前的初始几何;真正露出前会 fit 成内容高 left: useSavedLeft ? saved.left : base.left, top: useSavedTop ? saved.top : base.top, userSized: false }; } // 先不可见地挂到布局树,量准高度后再显示,避免用户看到尺寸变化 ov.style.visibility = 'hidden'; ov.style.display = 'block'; applyReplyPanelLayout(ov, layoutRect); bindReplyPanelFrame(ov); if (!ov.__userSizedPanel) { // 同步贴合:此时 form/preview 已在 DOM,且 display=block,可量到真实高度 fitReplyPanelToContent(ov, { save: true }); } // 强制一次 reflow,再露出最终尺寸 void (ov.offsetHeight); ov.style.visibility = ''; // 观察器只负责后续内容变化(输入/粘贴图/引用展开),不再承担“首开定高” ensureReplyPanelAutoFitObserver(ov); // 轻量跟一次:处理图片/字体等晚一帧才稳定的情况(首帧已是内容高,通常几乎无跳变) scheduleReplyPanelAutoFit(ov); // 显示归位按钮 const resetBtn = ov.querySelector('.qp-reset-btn'); if (resetBtn) { resetBtn.style.display = 'flex'; resetBtn.style.alignItems = 'center'; resetBtn.style.justifyContent = 'center'; } const ta = ov.querySelector('textarea[name="content"]'); if (ta) { // 允许垂直缩放;宽度仍跟浮窗走 ta.style.resize = 'vertical'; ta.style.fontSize = '14px'; ta.style.flex = 'none'; } // 聚焦 textarea if (ta) { ta.focus(); const val = ta.value; ta.value = ''; ta.value = val; // 恢复引用追记保存的光标位置(关闭浮窗时点引用号,focus 会失败, // 所以在引用追记中把期望光标位置暂存在元素上,这里再恢复) if (ta.__pendingQuoteCursorPos != null) { ta.setSelectionRange(ta.__pendingQuoteCursorPos, ta.__pendingQuoteCursorPos); ta.__pendingQuoteCursorPos = null; } } // Ctrl+Enter 发送并在成功后关闭浮窗 if (ta) { const form = ta.closest('form'); if (form && !ta.__qpCtrlEnterBound) { ta.__qpCtrlEnterBound = true; // 保留:提交成功后关闭浮窗,并清掉发送锁/超时器 document.addEventListener('replySuccess', () => { if (form.__submitLockTimer) { clearTimeout(form.__submitLockTimer); form.__submitLockTimer = 0; } form.__submitting = false; closeOverlay(); }); // 新增:调用抽取的绑定函数 bindCtrlEnter(ta); } } } const controller = { open, close: closeOverlay }; window.__xdexRightSidebarReplyController = controller; return controller; } function replaceRightSidebar() { const logRightSidebar = (stage, detail) => { console.log('[replaceRightSidebar]', stage, detail || {}); }; logRightSidebar('start', { 原始工具栏数: $('#h-tool').length, 既有拓展坞数: $('.hld__docker').length, 既有样式: !!document.getElementById('qp-style'), readyState: document.readyState, href: location.href, }); // 移除原始工具栏;early shell 可能已经处理过,这里兜底 const hadDockerBefore = !!document.querySelector('.hld__docker'); tryReplaceRightSidebarEarly(); logRightSidebar('original-toolbar-removed', { remainingToolbarCount: $('#h-tool').length, }); // 扩展坞 DOM:复用 early shell,避免加载时重复插入和重复重绘 let dockerDom = $('.hld__docker').first(); if (!dockerDom.length) { dockerDom = $(buildRightSidebarDockerHtml()).appendTo('body'); if (dockerDom[0]) markRightSidebarDockerBootMotion(dockerDom[0]); bindRightSidebarShellButtons(dockerDom[0]); } const dockerEl = dockerDom[0]; const earlyReady = !!(dockerEl && (dockerEl.dataset.xdexEarlyDocker === '1' || hadDockerBefore)); const styleExisted = !!document.getElementById('qp-style'); // 悬停展开只依赖 shell CSS;qp-style 仅服务 REPLY 浮窗。 // batch2 若坞已在且样式未装:改为空闲预热,避免与首次悬停抢主线程。 // 首次点 REPLY 时 open() 仍会 ensureReplyOverlayStyle 兜底。 // 但 Dark Reader class 必须立刻同步:设置面板等 UI 依赖 :root.xdex-darkreader-active。 try { ensureDarkReaderThemeSync(); } catch (e) {} if (!earlyReady && !styleExisted) { ensureReplyOverlayStyle(); logRightSidebar('style-injected-sync', { 样式ID: 'qp-style', reason: 'docker-not-early' }); } else if (!styleExisted) { scheduleReplyOverlayStylePrefetch(); logRightSidebar('style-deferred-idle', { 样式ID: 'qp-style', reason: 'hover-path-priority' }); } else { logRightSidebar('style-reused', { 样式ID: 'qp-style' }); } replaceRightSidebar.__darkReaderObserver = window.__xdexDarkReaderThemeObserver || null; logRightSidebar('docker-ready', { 拓展坞数: $('.hld__docker').length, 按钮数: dockerDom.find('.hld__docker-btns>div').length, 早期插入: dockerEl?.dataset.xdexEarlyDocker === '1', 支持Has选择器: !!(window.CSS && CSS.supports && CSS.supports('selector(:has(*))')), 样式策略: styleExisted ? 'reused' : (earlyReady ? 'idle-prefetch' : 'sync'), }); // REPLY 按钮:early shell 已绑定;这里兜底补绑,完整浮窗控制器仍延迟到首次点击再初始化 bindRightSidebarReplyButton(dockerEl); // 按设置应用「固定/隐藏」模式(early 已应用则幂等) try { applyDockDisplayMode(getDockDisplayMode()); } catch (e) {} // TOP/BOTTOM 在 early shell 阶段已绑定轻量滚动;这里不重复绑定,避免一次点击触发两套滚动。 } /* -------------------------------------------------- * tag 11. 拦截回复中间页 * -------------------------------------------------- */ // 回复提交文本工具:仅统一逐字符处理路径共用的 URL 范围保护原语 function findUrlRanges(text) { const re = /(?:(?:https?|ftp):\/\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}(?:\/[^\s]*)?|>>(?:No\.)?\d+)/gi; const ranges = []; for (const m of text.matchAll(re)) { ranges.push({ start: m.index, end: m.index + m[0].length }); } return ranges; } function inAnyRange(pos, ranges) { for (const r of ranges) { if (pos >= r.start && pos < r.end) return true; } return false; } function transformTextOutsideUrlRanges(text, transformChar) { const urlRanges = findUrlRanges(text); let out = ''; for (let i = 0; i < text.length; i++) { const ch = text[i]; out += inAnyRange(i, urlRanges) ? ch : transformChar(ch); } return out; } function fallbackInsertZWSP(text) { const hanRegex = /[\u4E00-\u9FFF]/; return transformTextOutsideUrlRanges(text, (ch) => hanRegex.test(ch) ? ch + '\u200B' : ch); } function insertZwspAfterHanAndAsciiLettersOutsideUrls(text) { const hanRegex = /[\u4E00-\u9FFF]/; const engRegex = /[A-Za-z]/; return transformTextOutsideUrlRanges(text, (ch) => (hanRegex.test(ch) || engRegex.test(ch)) ? ch + '\u200B' : ch); } // 回复提交图片/FormData 纯工具:不依赖表单提交状态 function getImageExtension(fileName) { return (fileName || '').split('.').pop().toLowerCase(); } function normalizeDetectedImageFormat(hex4, ext) { if (hex4 === '47494638') return 'gif'; if (hex4.startsWith('ffd8ff')) return 'jpeg'; if (hex4 === '89504e47') return 'png-container'; if (hex4 === '52494646') return 'webp-container'; if (hex4 === '424d') return 'bmp'; if (ext === 'gif') return 'gif'; if (ext === 'apng') return 'apng'; if (ext === 'webp') return 'webp'; if (ext === 'png') return 'png'; if (ext === 'jpg' || ext === 'jpeg') return 'jpeg'; if (ext === 'heic' || ext === 'heif' || ext === 'hif') return 'heic'; return 'unknown'; } function isHeifBrand(brand) { return /^(heic|heix|hevc|hevx|heim|heis|hevm|hevs|mif1|msf1|miaf)$/i.test(brand || ''); } function detectHeifFromHeader(bytes) { if (!bytes || bytes.length < 12) return null; const type = String.fromCharCode(bytes[4], bytes[5], bytes[6], bytes[7]); if (type !== 'ftyp') return null; const brands = []; brands.push(String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11])); for (let i = 16; i + 4 <= Math.min(bytes.length, 64); i += 4) { brands.push(String.fromCharCode(bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3])); } return brands.some(isHeifBrand) ? 'heic' : null; } function getStaticImageOutputType(originalType) { if (originalType === 'image/webp') return 'image/webp'; if (originalType === 'image/jpeg' || originalType === 'image/jpg') return 'image/jpeg'; return 'image/png'; } // 服务器图片体积上限(2048KB);预检与错误重试共用 const SUBMIT_IMAGE_LIMIT_BYTES = 2048 * 1024; function cloneFormData(sourceFd) { const cloned = new FormData(); for (const [key, value] of sourceFd.entries()) { cloned.append(key, value); } return cloned; } function cloneFormDataWithImage(sourceFd, image) { const retryFd = cloneFormData(sourceFd); retryFd.set('image', image); return retryFd; } function getCurrentSubmitContent(form, fallbackFd) { const textarea = form.querySelector('textarea[name="content"]'); return textarea ? textarea.value : (fallbackFd.get('content') || '').toString(); } function createContentRetryFormData(form, content) { const retryFd = new FormData(form); retryFd.set('content', content); // 预检压缩/转码过的图片优先复用,避免非法词重试时又上传原图 const cached = form && form.__preparedImage; if (cached && cached.file) { const fileInput = form.querySelector('input[type="file"][name="image"]'); const current = fileInput && fileInput.files && fileInput.files[0]; if (current && current.name === cached.name && current.size === cached.size && current.lastModified === cached.lastModified) { retryFd.set('image', cached.file); } } return retryFd; } function restoreOriginalSubmitContent(form) { const textarea = form.querySelector('textarea[name="content"]'); if (textarea && form.__originalContent != null) { textarea.value = form.__originalContent; } return form.__originalContent; } function readBlobAsArrayBuffer(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error || new Error('读取图片数据失败')); reader.readAsArrayBuffer(blob); }); } // 通过文件头部魔数检测真实图片格式(静态 / 动画) async function detectImageFormat(file) { const ext = getImageExtension(file.name); let headerBuffer; try { // HEIF/ISOBMFF 需要读到 ftyp 兼容品牌区(至少 32~64 字节) headerBuffer = await readBlobAsArrayBuffer(file.slice(0, 64)); } catch (_) { return normalizeDetectedImageFormat('', ext); } const arr = new Uint8Array(headerBuffer); const heifFormat = detectHeifFromHeader(arr); if (heifFormat) return heifFormat; const hex4 = Array.from(arr.subarray(0, 4), b => b.toString(16).padStart(2, '0')).join('').toLowerCase(); const detectedFormat = normalizeDetectedImageFormat(hex4, ext); // PNG / APNG: 89 50 4e 47 if (detectedFormat === 'png-container') { try { const fullBuffer = await readBlobAsArrayBuffer(file.slice(0, Math.min(file.size, 256 * 1024))); const fullArr = new Uint8Array(fullBuffer); let foundAcTl = false; for (let i = 8; i + 12 <= fullArr.length; ) { const len = (fullArr[i] << 24) | (fullArr[i + 1] << 16) | (fullArr[i + 2] << 8) | fullArr[i + 3]; const type = String.fromCharCode(fullArr[i + 4], fullArr[i + 5], fullArr[i + 6], fullArr[i + 7]); if (type === 'acTL') { foundAcTl = true; break; } if (type === 'IDAT') break; i += 12 + len; } return foundAcTl ? 'apng' : 'png'; } catch (_) { return 'png'; } } // WebP: 52 49 46 46 if (detectedFormat === 'webp-container') { try { const fullBuffer = await readBlobAsArrayBuffer(file.slice(0, Math.min(file.size, 4096))); const fullArr = new Uint8Array(fullBuffer); const str = String.fromCharCode.apply(null, Array.from(fullArr.subarray(0, Math.min(4096, fullArr.length)))); return str.indexOf('ANIM') !== -1 ? 'animated-webp' : 'webp'; } catch (_) { return 'webp'; } } return detectedFormat; } function clearSuccessfulSubmitPreview() { const previewBox = document.querySelector('.h-preview-box'); if (previewBox) { const cur = getCurrentCookie(); const cookieText = cur ? cur.name : '--'; // 先放一个占位 ID,等刷新完成后再更新 previewBox.innerHTML = buildEnhanceIslandPreviewHtml(false); if (typeof enableHDImage === 'function') { enableHDImage(previewBox); } } if (typeof refreshCookies === 'function') { try { refreshCookies(() => { try { if (typeof updatePreviewCookieId === 'function') { updatePreviewCookieId(); } } catch (err) { console.warn('[interceptReplyForm] updatePreviewCookieId after success failed', err); } }, false); } catch (err) { console.warn('[interceptReplyForm] refreshCookies after success failed', err); toast('回复已发送,但饼干刷新失败,请刷新页面', 0, { type: 'error' }); } } } // ---- HEIC/HEIF → PNG(浏览器原生通常不支持,走 heic2any)---- // 加载策略: // 1. Extension 环境:从 chrome-extension vendor 本地 <script> 注入(CSP 允许) // 脚本在 MAIN world 执行,heic2any 挂在 MAIN world 的 window 上, // 内容脚本(ISOLATED world)通过 unsafeWindow 访问。 // 2. Userscript 环境:GM_xmlhttpRequest 拉脚本文本,Blob URL 注入 <script> // (CSP 可能拦截 blob:,此时不可用) let heic2anyApiPromise = null; function _getHeic2Any() { // <script> 注入后 heic2any 挂在 MAIN world 的 window 上 // 内容脚本在 ISOLATED world,需要跨 world 访问 // 优先级:document.defaultView(标准 MAIN world window)> unsafeWindow > window const mainWin = typeof document !== 'undefined' && document.defaultView; if (mainWin && typeof mainWin.heic2any === 'function') return mainWin.heic2any; if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.heic2any === 'function') return unsafeWindow.heic2any; if (typeof window.heic2any === 'function') return window.heic2any; // 调试:看看 heic2any 到底挂在哪 if (mainWin) console.log('[heic2any] mainWin.heic2any:', typeof mainWin.heic2any, 'keys:', mainWin.heic2any ? Object.keys(mainWin.heic2any).slice(0,5) : 'N/A'); console.log('[heic2any] unsafeWindow.heic2any:', typeof unsafeWindow !== 'undefined' ? typeof unsafeWindow.heic2any : 'N/A', 'window.heic2any:', typeof window.heic2any); return null; } async function ensureHeic2AnyLoaded() { const existing = _getHeic2Any(); if (existing) return existing; if (heic2anyApiPromise) return heic2anyApiPromise; const runtime = getXDexRuntimeInfo(); const isExtension = runtime && runtime.kind === 'extension'; heic2anyApiPromise = (async () => { // Extension:通过 loader 桥接加载(解决 MAIN world ↔ ISOLATED world 隔离问题) // heic2any-loader.js 在 MAIN world 加载 heic2any 并通过 postMessage 提供转换服务 if (isExtension) { const loaderUrl = chrome.runtime.getURL('vendor/heic2any/heic2any-loader.js'); await new Promise((resolve, reject) => { const existing = document.querySelector(`script[data-xdex-heic2any-loader="1"]`); if (existing) { // 已注入,检查是否就绪 const ready = document.documentElement.getAttribute('data-xdex-heic2any-ready'); if (ready === 'ready') { resolve(); return; } if (ready === 'error') { reject(new Error('heic2any 桥接加载失败')); return; } } const s = document.createElement('script'); s.src = loaderUrl; s.dataset.xdexHeic2anyLoader = '1'; s.onload = () => { // loader 异步加载 heic2any.min.js,通过 DOM 属性信号就绪 let waited = 0; const poll = () => { const attr = document.documentElement.getAttribute('data-xdex-heic2any-ready'); if (attr === 'ready') { resolve(); return; } if (attr === 'error') { reject(new Error('heic2any 桥接加载失败')); return; } if (++waited > 50) { reject(new Error('heic2any 桥接加载超时(5s)')); return; } setTimeout(poll, 100); }; poll(); }; s.onerror = () => reject(new Error('heic2any-loader.js 加载失败')); document.documentElement.appendChild(s); }); // Extension 走 postMessage 桥接,不直接返回函数 return '__xdex_bridge__'; } // Userscript:用 GM_xmlhttpRequest 拉脚本文本,Blob URL 注入 if (typeof GM_xmlhttpRequest === 'function') { const scriptUrl = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/heic2any.min.js'; const scriptText = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: scriptUrl, onload: (resp) => { if (resp.status >= 200 && resp.status < 300 && resp.responseText) resolve(resp.responseText); else reject(new Error(`heic2any 下载失败: HTTP ${resp.status}`)); }, onerror: () => reject(new Error('heic2any 下载失败,请检查网络')) }); }); // 临时屏蔽 define,强制 heic2any UMD 走 self.heic2any 路径(同 Extension 逻辑) const wrappedText = 'var __xdexSavedDefine=window.define;if(typeof __xdexSavedDefine!=="undefined")window.define=void 0;\n' + scriptText + '\nif(typeof __xdexSavedDefine!=="undefined")window.define=__xdexSavedDefine;'; const blob = new Blob([wrappedText], { type: 'application/javascript' }); const blobUrl = URL.createObjectURL(blob); await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = blobUrl; s.dataset.xdexHeic2any = '1'; s.onload = () => { URL.revokeObjectURL(blobUrl); resolve(); }; s.onerror = () => { URL.revokeObjectURL(blobUrl); reject(new Error('heic2any Blob 加载失败')); }; document.documentElement.appendChild(s); }); const fn = _getHeic2Any(); if (fn) return fn; throw new Error('heic2any 已加载但未导出可用函数'); } throw new Error('无法加载 heic2any:无可用加载方式'); })().catch((err) => { heic2anyApiPromise = null; throw err; }); return heic2anyApiPromise; } async function convertHeicToPng(file) { // 优先尝试原生解码(少数浏览器/系统可能支持) try { if (typeof createImageBitmap === 'function') { const bitmap = await createImageBitmap(file); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); if (typeof bitmap.close === 'function') bitmap.close(); const pngBlob = await new Promise((resolve, reject) => { canvas.toBlob((b) => b ? resolve(b) : reject(new Error('HEIC→PNG toBlob 失败')), 'image/png'); }); return new File([pngBlob], file.name.replace(/\.(heic|heif|hif)$/i, '.png'), { type: 'image/png', lastModified: Date.now() }); } } catch (nativeErr) { console.warn('[convertHeicToPng] 原生解码失败,改用 heic2any:', nativeErr && nativeErr.message); } const heic2any = await ensureHeic2AnyLoaded(); let pngBlob; if (heic2any === '__xdex_bridge__') { // Extension:通过 postMessage 桥接调用 MAIN world 的 heic2any const arrayBuffer = await file.arrayBuffer(); const requestId = 'heic2any_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); pngBlob = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { window.removeEventListener('message', onResult); reject(new Error('heic2any 桥接转换超时(30s)')); }, 30000); function onResult(e) { if (!e.data || e.data.type !== 'xdex-heic2any-result' || e.data.requestId !== requestId) return; clearTimeout(timeout); window.removeEventListener('message', onResult); if (e.data.success) { resolve(new Blob([e.data.arrayBuffer], { type: 'image/png' })); } else { reject(new Error('heic2any 转换失败: ' + (e.data.error || '未知'))); } } window.addEventListener('message', onResult); // transfer ArrayBuffer 零拷贝发给 MAIN world window.postMessage({ type: 'xdex-heic2any-convert', requestId: requestId, arrayBuffer: arrayBuffer }, '*', [arrayBuffer]); }); } else { // Userscript:直接调用 heic2any 函数 const result = await heic2any({ blob: file, toType: 'image/png', quality: 1, multiple: false }); pngBlob = Array.isArray(result) ? result[0] : result; } if (!(pngBlob instanceof Blob)) { throw new Error('HEIC 转换未返回有效 PNG'); } return new File([pngBlob], file.name.replace(/\.(heic|heif|hif)$/i, '.png'), { type: 'image/png', lastModified: Date.now() }); } // ---- WebP 格式转换:静态 WebP → PNG,动态 WebP → APNG ---- async function convertWebpToAcceptedFormat(file, isAnimated) { if (!isAnimated) { // 静态 WebP → PNG:直接用 canvas 重绘 const bitmap = await createImageBitmap(file); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); bitmap.close(); const pngBlob = await new Promise((resolve, reject) => { canvas.toBlob((b) => b ? resolve(b) : reject(new Error('WebP→PNG toBlob 失败')), 'image/png'); }); return new File([pngBlob], file.name.replace(/\.webp$/i, '.png'), { type: 'image/png', lastModified: Date.now() }); } // 动态 WebP → APNG:用 ImageDecoder API 逐帧解码,保留动画 return convertAnimatedWebpToApng(file); } const STATIC_MAX_SIZE_KB = 2048; const STATIC_TARGET_LOWER_KB = 1900; const STATIC_ACCEPTABLE_LOWER_KB = 1850; const STATIC_MAX_ATTEMPTS = 5; async function compressImageToSize(file, maxSizeKB = STATIC_MAX_SIZE_KB, minQuality = 0.6, detectedFormat) { const maxSizeBytes = STATIC_MAX_SIZE_KB * 1024; const targetUpperBytes = maxSizeBytes; const targetLowerBytes = STATIC_TARGET_LOWER_KB * 1024; if (file.size <= maxSizeBytes) { return file; } console.log(`[compressImage] 开始压缩: ${(file.size / 1024).toFixed(1)}KB -> 目标区间 ${(targetLowerBytes / 1024).toFixed(0)}-${maxSizeKB}KB`); const startTime = performance.now(); return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = async () => { URL.revokeObjectURL(url); // 优先使用外部传入的真实格式(magic bytes 检测),避免 file.type 被后缀名误导 // WebP 和 animated-webp 统一输出为 PNG(服务器不接受 WebP) const originalType = detectedFormat ? (detectedFormat === 'png' ? 'image/png' : detectedFormat === 'jpeg' ? 'image/jpeg' : (detectedFormat === 'webp' || detectedFormat === 'animated-webp') ? 'image/png' : file.type || 'image/jpeg') : (file.type || 'image/jpeg'); const outputType = getStaticImageOutputType(originalType); const drawToCanvas = (scale) => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = Math.max(1, Math.floor(img.width * scale)); canvas.height = Math.max(1, Math.floor(img.height * scale)); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); return canvas; }; const canvasToBlob = (canvas, quality) => new Promise((resolveBlob, rejectBlob) => { canvas.toBlob((blob) => { if (!blob) { rejectBlob(new Error('图片压缩失败')); return; } resolveBlob(blob); }, outputType, quality); }); const toFile = (blob) => new File([blob], file.name, { type: outputType, lastModified: Date.now() }); const formatSupportsQuality = outputType !== 'image/png'; const clampScale = (scale, minScale = 0.12, maxScale = 1) => Math.min(maxScale, Math.max(minScale, scale)); let bestBlob = null; let bestScore = Infinity; let totalAttempts = 0; const considerBlob = (blob) => { if (blob.size > targetUpperBytes) return; const score = targetUpperBytes - blob.size; if (score < bestScore) { bestScore = score; bestBlob = blob; } }; const searchBestQualityAtScale = async (scale) => { const canvas = drawToCanvas(scale); if (!formatSupportsQuality) { totalAttempts++; const blob = await canvasToBlob(canvas, 0.92); console.log(`[compressImage] 尝试#${totalAttempts}: 缩放=${scale.toFixed(3)}, 质量=png-default -> ${(blob.size / 1024).toFixed(1)}KB`); considerBlob(blob); return { blob, exceeded: blob.size > targetUpperBytes, hitRange: blob.size >= targetLowerBytes && blob.size <= targetUpperBytes }; } let low = minQuality; let high = 0.95; let localBest = null; let localBestScore = Infinity; for (let i = 0; i < STATIC_MAX_ATTEMPTS; i++) { const quality = (low + high) / 2; totalAttempts++; const blob = await canvasToBlob(canvas, quality); const sizeKB = blob.size / 1024; console.log(`[compressImage] 尝试#${totalAttempts}: 缩放=${scale.toFixed(3)}, 质量=${quality.toFixed(4)} -> ${sizeKB.toFixed(1)}KB`); if (blob.size <= targetUpperBytes) { const score = targetUpperBytes - blob.size; if (score < localBestScore) { localBestScore = score; localBest = blob; } considerBlob(blob); low = quality; } else { high = quality; } } // ✨ 精细搜索:从 high(≈0.95) 逐步提升到 1.0,寻找最接近限制的质量 if (localBest && localBest.size < targetLowerBytes) { console.log(`[compressImage] 精细搜索:当前最佳 ${(localBest.size / 1024).toFixed(1)}KB < ${(STATIC_TARGET_LOWER_KB)}KB,尝试提高质量……`); const fineQualities = [0.96, 0.965, 0.97, 0.975, 0.98, 0.985, 0.99, 0.995, 0.998]; for (const q of fineQualities) { totalAttempts++; const blob = await canvasToBlob(canvas, q); const sizeKB = blob.size / 1024; console.log(`[compressImage] 尝试#${totalAttempts}: 缩放=${scale.toFixed(3)}, 质量=${q.toFixed(4)}(精细) -> ${sizeKB.toFixed(1)}KB`); considerBlob(blob); if (blob.size <= targetUpperBytes) { const score = targetUpperBytes - blob.size; if (score < localBestScore) { localBestScore = score; localBest = blob; } if (blob.size >= targetLowerBytes) { return { blob: localBest, exceeded: false, hitRange: true }; } } else { break; } } } if (localBest) { return { blob: localBest, exceeded: false, hitRange: localBest.size >= targetLowerBytes && localBest.size <= targetUpperBytes }; } const fallbackBlob = await canvasToBlob(canvas, minQuality); totalAttempts++; console.log(`[compressImage] 尝试#${totalAttempts}: 缩放=${scale.toFixed(3)}, 质量=${minQuality.toFixed(4)}(fallback) -> ${(fallbackBlob.size / 1024).toFixed(1)}KB`); considerBlob(fallbackBlob); return { blob: fallbackBlob, exceeded: fallbackBlob.size > targetUpperBytes, hitRange: fallbackBlob.size >= targetLowerBytes && fallbackBlob.size <= targetUpperBytes }; }; const searchBestPngScale = async () => { const relaxedLowerBytes = Math.max(Math.floor(maxSizeBytes * 0.84), Math.floor(targetLowerBytes * 0.88)); const baseScale = Math.sqrt(targetUpperBytes / file.size); // 硬下限:只防过小。不要用 baseScale*0.7 当下限——canvas 重编码后体积常比原文件更差, // 否则会像 21MB PNG 那样把预估 scale(≈0.17) 卡在 0.215 动不了。 const HARD_MIN_SCALE = 0.05; let minScale = HARD_MIN_SCALE; let scale = clampScale(baseScale * 1.08, minScale, 0.92); let lastUnderLimitBlob = null; const seenScales = new Set(); for (let phase = 0; phase < 6; phase++) { const scaleKey = scale.toFixed(4); if (seenScales.has(scaleKey)) break; seenScales.add(scaleKey); const result = await searchBestQualityAtScale(scale); if (!result.exceeded) lastUnderLimitBlob = result.blob; if (result.hitRange) return result; if (result.exceeded) { const remain = 6 - phase - 1; const ratio = result.blob.size / targetUpperBytes; let safety = 0.95; if (ratio > 2.0 || remain <= 0) safety = 0.85; else if (ratio > 1.4 || remain <= 1) safety = 0.90; const predicted = scale * Math.sqrt(targetUpperBytes / Math.max(result.blob.size, 1)) * safety; // 预估低于当前下限时下调下限,避免 clamp 把步进掐死 if (predicted < minScale) { minScale = clampScale(predicted * 0.95, HARD_MIN_SCALE, minScale); } let nextScale = clampScale(predicted, minScale, scale * 0.98); // 仍几乎不动但还超限:强制再砍一刀 if (Math.abs(nextScale - scale) < 0.005) { const forced = clampScale(predicted * 0.9, HARD_MIN_SCALE, scale * 0.9); if (forced < scale - 0.005) { nextScale = forced; minScale = Math.min(minScale, forced); } else { console.log(`[compressImage] PNG超限但无法继续缩小: ratio=${ratio.toFixed(2)}, scale=${scale.toFixed(3)}, predicted=${predicted.toFixed(3)}`); break; } } console.log(`[compressImage] PNG超限 ratio=${ratio.toFixed(2)}, remain=${remain}, scale ${scale.toFixed(3)} -> ${nextScale.toFixed(3)} (safety=${safety}, min=${minScale.toFixed(3)})`); scale = nextScale; continue; } if (result.blob.size >= relaxedLowerBytes) { return { blob: result.blob, exceeded: false, hitRange: false }; } const growRatio = Math.sqrt(targetUpperBytes / Math.max(result.blob.size, 1)); const nextScale = clampScale(scale * Math.min(growRatio * 0.985, 1.12), minScale, 0.98); if (nextScale <= scale + 0.004 || nextScale >= 0.995) break; scale = nextScale; } if (bestBlob) { return { blob: bestBlob, exceeded: false, hitRange: bestBlob.size >= targetLowerBytes && bestBlob.size <= targetUpperBytes }; } if (lastUnderLimitBlob) { return { blob: lastUnderLimitBlob, exceeded: false, hitRange: lastUnderLimitBlob.size >= targetLowerBytes && lastUnderLimitBlob.size <= targetUpperBytes }; } return { blob: null, exceeded: true, hitRange: false }; }; try { if (!formatSupportsQuality) { const pngResult = await searchBestPngScale(); const finalBlob = pngResult.blob || bestBlob; if (finalBlob) { const elapsed = ((performance.now() - startTime) / 1000).toFixed(2); console.log(`[compressImage] ✓ PNG完成: ${(file.size / 1024).toFixed(1)}KB -> ${(finalBlob.size / 1024).toFixed(1)}KB, ${totalAttempts}次尝试, ${elapsed}秒`); resolve(toFile(finalBlob)); return; } reject(new Error('无法将PNG压缩到限制以内')); return; } let result = await searchBestQualityAtScale(1); if (result.hitRange) { const elapsed = ((performance.now() - startTime) / 1000).toFixed(2); console.log(`[compressImage] ✓ 原尺寸命中目标: ${(bestBlob.size / 1024).toFixed(1)}KB, ${totalAttempts}次尝试, ${elapsed}秒`); resolve(toFile(bestBlob)); return; } if (result.exceeded) { // 实测体积反推 scale(∝√面积);下限放宽到 0.2,避免卡在 0.5 仍超限 const STATIC_MIN_SCALE = 0.2; const maxPhases = 4; let scale = clampScale(Math.sqrt(targetUpperBytes / result.blob.size) * 0.95, STATIC_MIN_SCALE, 0.98); console.log(`[compressImage] 原尺寸超限,预估缩放=${scale.toFixed(3)}`); for (let phase = 0; phase < maxPhases; phase++) { result = await searchBestQualityAtScale(scale); if (result.hitRange) break; if (result.exceeded) { const remain = maxPhases - phase - 1; const ratio = result.blob.size / targetUpperBytes; // 安全系数:剩余次数少 / 超限倍率大时更狠,避免次数耗尽仍超 let safety = 0.95; if (ratio > 2.0 || remain <= 0) safety = 0.85; else if (ratio > 1.35 || remain <= 1) safety = 0.90; const predicted = scale * Math.sqrt(targetUpperBytes / Math.max(result.blob.size, 1)) * safety; const nextScale = clampScale(predicted, STATIC_MIN_SCALE, scale * 0.98); console.log(`[compressImage] 超限 ratio=${ratio.toFixed(2)}, remain=${remain}, scale ${scale.toFixed(3)} -> ${nextScale.toFixed(3)} (safety=${safety})`); if (Math.abs(nextScale - scale) < 0.004) break; scale = nextScale; } else if (bestBlob && bestBlob.size < targetLowerBytes) { scale = clampScale(scale * 1.03, STATIC_MIN_SCALE, 1); if (scale >= 0.999) break; } else { break; } } } if (bestBlob) { const elapsed = ((performance.now() - startTime) / 1000).toFixed(2); console.log(`[compressImage] ✓ 完成: ${(file.size / 1024).toFixed(1)}KB -> ${(bestBlob.size / 1024).toFixed(1)}KB, ${totalAttempts}次尝试, ${elapsed}秒`); resolve(toFile(bestBlob)); return; } reject(new Error('无法将图片压缩到目标大小')); } catch (err) { reject(err); } }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')); }; img.src = url; }); } const GIF_MAX_SIZE_KB = 2048; const GIF_TARGET_LOWER_KB = 1900; const GIF_ACCEPTABLE_LOWER_KB = 1850; const GIF_MAX_ORIGINAL_KB = 30 * 1024;//目前限制30MB以内 const GIF_MAX_LONG_EDGE = 1600; const GIF_MAX_ATTEMPTS = 3;//目前最多重试3次 let gifsicleApiPromise = null; function clampNumber(value, min, max) { return Math.min(max, Math.max(min, value)); } function formatKB(sizeBytes) { return `${(sizeBytes / 1024).toFixed(1)}KB`; } function getGifDimensions(file) { return new Promise((resolve) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const result = { width: img.naturalWidth || img.width || 0, height: img.naturalHeight || img.height || 0 }; URL.revokeObjectURL(url); resolve(result); }; img.onerror = () => { URL.revokeObjectURL(url); resolve({ width: 0, height: 0 }); }; img.src = url; }); } async function ensureGifsicleLoaded() { if (window.__xdexGifsicleModule) { return window.__xdexGifsicleModule; } if (!gifsicleApiPromise) { gifsicleApiPromise = import('https://cdn.jsdelivr.net/npm/gifsicle-wasm-browser/dist/gifsicle.min.js') .then((mod) => { const api = (mod && (mod.default || mod.gifsicle || mod)) || null; if (!api) { throw new Error('GIF压缩库未返回可用模块'); } window.__xdexGifsicleModule = api; return api; }) .catch((err) => { gifsicleApiPromise = null; throw err; }); } return gifsicleApiPromise; } async function runGifsicleAttempt(api, inputFile, args) { const fileName = '/tem/input.gif'; const outName = '/out/out.gif'; const commandText = [...args, fileName, '-o', outName].join(' '); if (typeof api.run === 'function') { const result = await api.run({ input: [{ file: inputFile, name: fileName }], command: [commandText] }); const output = (result && (result.output || result.files || result)) || []; const outFile = Array.isArray(output) ? output.find((item) => item && (((item.name || '') === outName) || /out\.gif$/i.test(item.name || ''))) : null; const data = outFile && (outFile.data || outFile.buffer || outFile.content || outFile.file || outFile.blob); if (data instanceof Blob) return data; if (data instanceof Uint8Array) return new Blob([data], { type: 'image/gif' }); if (data instanceof ArrayBuffer) return new Blob([new Uint8Array(data)], { type: 'image/gif' }); if (outFile instanceof Blob) return outFile; throw new Error('gifsicle.run 未返回可用输出'); } if (api.default && typeof api.default.run === 'function') { return runGifsicleAttempt(api.default, inputFile, args); } throw new Error('未识别的 gifsicle-wasm-browser API 形态'); } // GIF 结构修复:用 gifsicle 重新编码(不压缩,只修复 GCT 等结构问题) async function reencodeGifWithGifsicle(file) { const api = await ensureGifsicleLoaded(); const blob = await runGifsicleAttempt(api, file, ['--no-warnings']); return new File([blob], file.name.replace(/\.gif$/i, '.gif'), { type: 'image/gif', lastModified: Date.now() }); } async function compressGifToSize(file, options = {}) { const now = () => (typeof performance !== 'undefined' && performance && typeof performance.now === 'function') ? performance.now() : Date.now(); const finiteNumber = (value, digits = 2) => Number.isFinite(value) ? Number(value.toFixed(digits)) : 0; const totalStartTime = now(); let stage = '初始化'; let completedAttempts = 0; try { const maxSizeKB = options.maxSizeKB || GIF_MAX_SIZE_KB; const targetLowerKB = options.targetLowerKB || GIF_TARGET_LOWER_KB; const acceptableLowerKB = options.acceptableLowerKB || GIF_ACCEPTABLE_LOWER_KB; const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const maxBytes = maxSizeKB * 1024; const lowerBytes = targetLowerKB * 1024; const acceptableLowerBytes = acceptableLowerKB * 1024; const originalKB = file.size / 1024; if (file.size <= maxBytes) { const totalElapsedMs = finiteNumber(now() - totalStartTime); console.log('[X岛-EX][GIF压缩][汇总]', { totalElapsedMs, completedAttempts: 0, originalKB: finiteNumber(originalKB, 1), finalKB: finiteNumber(originalKB, 1), compressionRate: 1, reductionPercent: 0, finalLossy: null, finalColors: null, finalScale: null, finalCommand: '无需压缩' }); return { file, summary: '原始 GIF 已在目标大小内,无需压缩', attempts: [], durationMs: totalElapsedMs }; } if (originalKB > GIF_MAX_ORIGINAL_KB) { throw new Error(`GIF 原始体积过大(${formatKB(file.size)}),限制为 <= ${GIF_MAX_ORIGINAL_KB}KB`); } stage = '读取GIF尺寸'; const dimensionsStartTime = now(); const { width, height } = await getGifDimensions(file); const dimensionsElapsedMs = finiteNumber(now() - dimensionsStartTime); if (width > 0 && height > 0) { const longEdge = Math.max(width, height); if (longEdge > GIF_MAX_LONG_EDGE) { throw new Error(`GIF 尺寸过大(${width}x${height}),限制长边 <= ${GIF_MAX_LONG_EDGE}`); } } stage = '加载gifsicle模块'; const moduleLoadStartTime = now(); const api = await ensureGifsicleLoaded(); const moduleLoadElapsedMs = finiteNumber(now() - moduleLoadStartTime); const attempts = []; let bestBlob = null; let bestArgs = null; let bestAttemptLog = null; const oversizeRatio = file.size / maxBytes; let scale = clampNumber(Math.sqrt(maxBytes / file.size) * (oversizeRatio > 2.5 ? 1.32 : (oversizeRatio > 1.6 ? 1.18 : 1.06)), 0.22, 0.98); let lossy = oversizeRatio > 2.5 ? 40 : (oversizeRatio > 1.6 ? 30 : 20); let colors = oversizeRatio > 2.5 ? 128 : 160; const seenConfigs = new Set(); console.log(`[compressGif] 开始压缩: ${formatKB(file.size)} -> 目标区间 ${targetLowerKB}-${maxSizeKB}KB, 倍率=${oversizeRatio.toFixed(2)}`); for (let i = 0; i < GIF_MAX_ATTEMPTS; i++) { const args = ['-O3', `--lossy=${Math.round(lossy)}`, '--colors', String(Math.round(colors))]; if (scale < 0.995) { args.push('--scale', scale.toFixed(3)); } const configKey = `${Math.round(lossy)}|${Math.round(colors)}|${scale < 0.995 ? scale.toFixed(3) : '1.000'}`; if (seenConfigs.has(configKey)) { scale = clampNumber(scale * 0.97, 0.16, 0.98); lossy = clampNumber(Math.round(lossy) + 8, 0, 200); colors = clampNumber(Math.round(colors) - 16, 48, 256); continue; } seenConfigs.add(configKey); const attemptStartTime = now(); stage = `gifsicle压缩第${i + 1}轮`; try { const blob = await runGifsicleAttempt(api, file, args); completedAttempts++; const sizeKB = blob.size / 1024; const hitTarget = blob.size <= maxBytes; const inRange = blob.size >= lowerBytes && blob.size <= maxBytes; const acceptable = hitTarget && blob.size >= acceptableLowerBytes; const command = [...args, '/tem/input.gif', '-o', '/out/out.gif'].join(' '); const attemptLog = { index: i + 1, args: args.join(' '), sizeKB: Number(sizeKB.toFixed(1)), hitTarget, inRange }; attempts.push(attemptLog); console.log('[X岛-EX][GIF压缩][轮次]', { attempt: `${attemptLog.index}/${GIF_MAX_ATTEMPTS}`, inputKB: finiteNumber(originalKB, 1), outputKB: finiteNumber(sizeKB, 1), compressionRate: finiteNumber(blob.size / file.size, 4), reductionPercent: finiteNumber((1 - blob.size / file.size) * 100, 2), lossy: Math.round(lossy), colors: Math.round(colors), scale: scale < 0.995 ? finiteNumber(scale, 3) : 1, command, elapsedMs: finiteNumber(now() - attemptStartTime), hitTargetRange: inRange, acceptable: acceptable, overLimit: !hitTarget }); if (onProgress) { onProgress({ index: attemptLog.index, total: GIF_MAX_ATTEMPTS, originalKB: Number(originalKB.toFixed(1)), currentKB: attemptLog.sizeKB, args: attemptLog.args, hitTarget, inRange }); } if (hitTarget) { if (!bestBlob || blob.size > bestBlob.size) { bestBlob = blob; bestArgs = args; bestAttemptLog = { lossy: Math.round(lossy), colors: Math.round(colors), scale: scale < 0.995 ? finiteNumber(scale, 3) : 1, command }; } if (inRange || blob.size >= acceptableLowerBytes) { break; } scale = clampNumber(scale * 1.08, 0.16, 0.98); lossy = clampNumber(Math.round(lossy) - 12, 0, 200); colors = clampNumber(Math.round(colors) + 32, 48, 256); } else { // 默认温和步进;剩余次数不足或超限倍率大时,按实测体积做 scale² 激进跳变 const remain = GIF_MAX_ATTEMPTS - (i + 1); const ratio = blob.size / maxBytes; let nextScale = scale * 0.90; let nextLossy = Math.round(lossy) + 18; let nextColors = Math.round(colors) - 24; if (ratio > 1.45 || (remain <= 1 && ratio > 1.15) || (remain <= 0 && ratio > 1.05)) { const safety = remain <= 1 ? 0.88 : 0.92; const predictedScale = scale * Math.sqrt(maxBytes / Math.max(blob.size, 1)) * safety; nextScale = Math.min(nextScale, predictedScale); nextLossy = Math.max(nextLossy, Math.round(lossy) + (remain <= 1 ? 40 : 28)); nextColors = Math.min(nextColors, Math.round(colors) - (remain <= 1 ? 48 : 32)); console.log(`[compressGif] 激进跳变: ratio=${ratio.toFixed(2)}, remain=${remain}, scale->${nextScale.toFixed(3)}, lossy->${nextLossy}, colors->${nextColors}`); } scale = clampNumber(nextScale, 0.16, 0.98); lossy = clampNumber(nextLossy, 0, 200); colors = clampNumber(nextColors, 48, 256); } } catch (error) { completedAttempts++; const message = error && error.message ? error.message : String(error); attempts.push({ index: i + 1, args: args.join(' '), error: message }); console.error('[X岛-EX][GIF压缩][失败]', { stage: stage, elapsedMs: finiteNumber(now() - totalStartTime), completedAttempts: completedAttempts, attempt: `${i + 1}/${GIF_MAX_ATTEMPTS}`, command: [...args, '/tem/input.gif', '-o', '/out/out.gif'].join(' '), error: message }); if (onProgress) { onProgress({ index: i + 1, total: GIF_MAX_ATTEMPTS, originalKB: Number(originalKB.toFixed(1)), currentKB: null, args: args.join(' '), error: message, hitTarget: false, inRange: false }); } // 失败轮次也按剩余次数适度加压,避免空耗预算 const remain = GIF_MAX_ATTEMPTS - (i + 1); scale = clampNumber(scale * (remain <= 1 ? 0.82 : 0.90), 0.16, 0.98); lossy = clampNumber(Math.round(lossy) + (remain <= 1 ? 28 : 18), 0, 200); colors = clampNumber(Math.round(colors) - (remain <= 1 ? 32 : 24), 48, 256); } } if (!bestBlob) { throw new Error('GIF 压缩后仍无法降到 2048KB 以下'); } const compressedFile = new File([bestBlob], file.name.replace(/\.gif$/i, '') + '-compressed.gif', { type: 'image/gif', lastModified: Date.now() }); const summary = `最佳命令: ${bestArgs.join(' ')};原始 ${formatKB(file.size)} -> 压缩后 ${formatKB(compressedFile.size)}`; const durationMs = finiteNumber(now() - totalStartTime); console.log('[X岛-EX][GIF压缩][汇总]', { totalElapsedMs: durationMs, dimensionsElapsedMs, moduleLoadElapsedMs, completedAttempts, originalKB: finiteNumber(originalKB, 1), finalKB: finiteNumber(compressedFile.size / 1024, 1), compressionRate: finiteNumber(compressedFile.size / file.size, 4), reductionPercent: finiteNumber((1 - compressedFile.size / file.size) * 100, 2), finalLossy: bestAttemptLog.lossy, finalColors: bestAttemptLog.colors, finalScale: bestAttemptLog.scale, finalCommand: bestAttemptLog.command }); return { file: compressedFile, summary, attempts, durationMs }; } catch (error) { const message = error && error.message ? error.message : String(error); console.error('[X岛-EX][GIF压缩][失败]', { stage: stage, elapsedMs: finiteNumber(now() - totalStartTime), completedAttempts: completedAttempts, error: message }); throw error; } } // ---- APNG 组装工具(供 compressApngToSize 和 convertAnimatedWebpToApng 共用)---- const _apngCrcTable = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } table[n] = c; } return table; })(); const _apngCrc32 = (data) => { let crc = 0xFFFFFFFF; for (let i = 0; i < data.length; i++) { crc = _apngCrcTable[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8); } return (crc ^ 0xFFFFFFFF) >>> 0; }; const _apngReadPngChunks = (u8) => { const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength); let pos = 8; // 跳过 PNG signature const chunks = []; while (pos + 8 <= u8.length) { const len = dv.getUint32(pos); pos += 4; const type = String.fromCharCode(u8[pos], u8[pos+1], u8[pos+2], u8[pos+3]); pos += 4; if (pos + len + 4 > u8.length) break; chunks.push({ type, data: u8.slice(pos, pos + len) }); pos += len + 4; // data + crc } return chunks; }; const _apngBuild = (ihdrData, frameChunks, delays, numPlays) => { let totalSize = 8 + (12 + 13) + (12 + 8); // sig + IHDR + acTL for (let i = 0; i < frameChunks.length; i++) { totalSize += 12 + 26; // fcTL for (const idat of frameChunks[i]) { totalSize += (i === 0 ? 12 : 12 + 4) + idat.length; // IDAT vs fdAT } } totalSize += 12; // IEND const buf = new ArrayBuffer(totalSize); const u8 = new Uint8Array(buf); const dv = new DataView(buf); let o = 0; // PNG signature u8.set([137, 80, 78, 71, 13, 10, 26, 10], 0); o = 8; // IHDR dv.setUint32(o, 13); o += 4; u8.set([73, 72, 68, 82], o); o += 4; u8.set(ihdrData, o); o += 13; dv.setUint32(o, _apngCrc32(u8.slice(o - 4 - 13, o))); o += 4; // acTL dv.setUint32(o, 8); o += 4; u8.set([97, 99, 84, 76], o); o += 4; dv.setUint32(o, frameChunks.length); o += 4; dv.setUint32(o, numPlays); o += 4; dv.setUint32(o, _apngCrc32(u8.slice(o - 4 - 8, o))); o += 4; // Frames let seqNum = 0; for (let i = 0; i < frameChunks.length; i++) { const w = dv.getUint32(16); const h = dv.getUint32(20); const delay = delays[i] || 100; dv.setUint32(o, 26); o += 4; u8.set([102, 99, 84, 76], o); o += 4; dv.setUint32(o, seqNum++); o += 4; dv.setUint32(o, w); o += 4; dv.setUint32(o, h); o += 4; dv.setUint32(o, 0); o += 4; dv.setUint32(o, 0); o += 4; dv.setUint16(o, delay); o += 2; dv.setUint16(o, 1000); o += 2; // 全画布预合成帧:每帧都是完整 RGBA,必须 SOURCE 替换,不能 OVER 叠透明 u8[o++] = 0; // dispose_op: NONE u8[o++] = 0; // blend_op: SOURCE(0=替换,保留本帧透明区) dv.setUint32(o, _apngCrc32(u8.slice(o - 4 - 26, o))); o += 4; if (i === 0) { for (const idatData of frameChunks[i]) { dv.setUint32(o, idatData.length); o += 4; u8.set([73, 68, 65, 84], o); o += 4; u8.set(idatData, o); o += idatData.length; dv.setUint32(o, _apngCrc32(u8.slice(o - 4 - idatData.length, o))); o += 4; } } else { for (const idatData of frameChunks[i]) { const fdatPayloadLen = 4 + idatData.length; dv.setUint32(o, fdatPayloadLen); o += 4; u8.set([102, 100, 65, 84], o); o += 4; dv.setUint32(o, seqNum++); o += 4; u8.set(idatData, o); o += idatData.length; dv.setUint32(o, _apngCrc32(u8.slice(o - 4 - idatData.length - 4, o))); o += 4; } } } // IEND dv.setUint32(o, 0); o += 4; u8.set([73, 69, 78, 68], o); o += 4; dv.setUint32(o, _apngCrc32(u8.slice(o - 4, o))); o += 4; return buf; }; // ---- WebP ANMF bitstream → 单帧 WebP 容器包装 ---- // ANMF 子 bitstream 已包含完整 chunk headers(ALPH+VP8 / VP8L / VP8), // 只需外包 RIFF/WEBP/VP8X。frameW/frameH 必须是真实像素尺寸(不是 Minus One)。 // // 关键:RIFF size 字段 = 文件总长 - 8。之前写成 new Uint8Array(12 + size) // 会多出 4 字节尾零,Blob 长度 > RIFF 声明长度,浏览器解码器直接拒文件。 function _wrapBitstreamAsWebp(bitstream, frameW, frameH) { const hasAlpha = (String.fromCharCode(bitstream[0], bitstream[1], bitstream[2], bitstream[3]) === 'ALPH') || (bitstream[0] === 0x2F); // VP8L 自带 alpha const vp8x = new Uint8Array(10); if (hasAlpha) vp8x[0] = 0x10; // bit4 = has_alpha const w1 = Math.max(0, frameW - 1); const h1 = Math.max(0, frameH - 1); vp8x[4] = w1 & 0xFF; vp8x[5] = (w1 >> 8) & 0xFF; vp8x[6] = (w1 >> 16) & 0xFF; vp8x[7] = h1 & 0xFF; vp8x[8] = (h1 >> 8) & 0xFF; vp8x[9] = (h1 >> 16) & 0xFF; // layout: RIFF(4) + size(4) + WEBP(4) + VP8X_chunk(8+10=18) + bitstream(+pad) const pad = bitstream.length & 1; // RIFF 偶数对齐 const payloadAfterRiffHeader = 4 + 18 + bitstream.length + pad; // = size 字段值 const fileSize = 8 + payloadAfterRiffHeader; // 真实文件长度,必须精确 const result = new Uint8Array(fileSize); const dv = new DataView(result.buffer, result.byteOffset, result.byteLength); result.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF dv.setUint32(4, payloadAfterRiffHeader, true); result.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP result.set([0x56, 0x50, 0x38, 0x58], 12); // VP8X dv.setUint32(16, 10, true); result.set(vp8x, 20); result.set(bitstream, 30); // pad 字节已由 new Uint8Array 填 0,无需再写 return result; } // 解码单帧 WebP:createImageBitmap 优先,Image 兜底(比 ImageDecoder 对单帧更稳) async function _decodeSingleFrameWebp(frameBytes) { const blob = new Blob([frameBytes], { type: 'image/webp' }); if (typeof createImageBitmap === 'function') { try { return await createImageBitmap(blob); } catch (e) { console.warn('[convertAnimatedWebpToApng] createImageBitmap 失败,尝试 Image:', e && e.message); } } return await new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(blob); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image 加载单帧 WebP 失败')); }; img.src = url; }); } // ---- 动态 WebP → APNG(保留动画:手动解析 ANMF + 逐帧解码合成)---- async function convertAnimatedWebpToApng(file) { const buffer = await file.arrayBuffer(); const src = new Uint8Array(buffer); const dv = new DataView(buffer); // 验证 RIFF/WEBP if (String.fromCharCode(src[0], src[1], src[2], src[3]) !== 'RIFF' || String.fromCharCode(src[8], src[9], src[10], src[11]) !== 'WEBP') { throw new Error('不是有效的 WebP 文件'); } // 解析 RIFF chunks:VP8X(画布尺寸)、ANIM(背景色/循环)、ANMF(帧数据) // WebP 规范: // - VP8X / ANMF 的 width/height 存的是 Minus One(实际 = 值 + 1) // - ANMF 的 x/y 存的是像素偏移 / 2(实际 = 值 * 2) let pos = 12; let canvasW = 0, canvasH = 0; let animBgColor = 0xFFFFFFFF; // WebP 默认白色不透明 const anmfFrames = []; while (pos + 8 <= src.length) { const chunkId = String.fromCharCode(src[pos], src[pos+1], src[pos+2], src[pos+3]); const chunkLen = dv.getUint32(pos + 4, true); // little-endian const dataStart = pos + 8; if (chunkId === 'VP8X') { canvasW = (src[dataStart+4] | (src[dataStart+5] << 8) | (src[dataStart+6] << 16)) + 1; canvasH = (src[dataStart+7] | (src[dataStart+8] << 8) | (src[dataStart+9] << 16)) + 1; } else if (chunkId === 'ANIM') { // BGRA little-endian animBgColor = dv.getUint32(dataStart, true); } else if (chunkId === 'ANMF') { const xRaw = (src[dataStart] | (src[dataStart+1] << 8) | (src[dataStart+2] << 16)); const yRaw = (src[dataStart+3] | (src[dataStart+4] << 8) | (src[dataStart+5] << 16)); const wRaw = (src[dataStart+6] | (src[dataStart+7] << 8) | (src[dataStart+8] << 16)); const hRaw = (src[dataStart+9] | (src[dataStart+10] << 8) | (src[dataStart+11] << 16)); const duration = (src[dataStart+12] | (src[dataStart+13] << 8) | (src[dataStart+14] << 16)); const flags = src[dataStart+15]; const disposal = (flags >> 1) & 1; // bit1: 0=none, 1=restore to bg const blending = flags & 1; // bit0: 0=alpha blend, 1=no blend const bitstream = src.slice(dataStart + 16, dataStart + chunkLen); anmfFrames.push({ x: xRaw * 2, y: yRaw * 2, w: wRaw + 1, h: hRaw + 1, duration: Math.max(duration || 100, 20), disposal, blending, bitstream }); } pos = dataStart + chunkLen + (chunkLen & 1); // RIFF 2-byte aligned } if (anmfFrames.length === 0) { throw new Error('WebP 中未找到动画帧(ANMF)'); } if (canvasW === 0 || canvasH === 0) { canvasW = anmfFrames[0].w; canvasH = anmfFrames[0].h; } console.log(`[convertAnimatedWebpToApng] 画布: ${canvasW}x${canvasH}, ${anmfFrames.length}帧, 首帧 ${anmfFrames[0].w}x${anmfFrames[0].h}`); // 逐帧解码 + 合成 // 透明底处理:很多动画 WebP(尤其贴纸)ANIM 背景色写成不透明白 0xFFFFFFFF, // 但实际展示依赖帧 alpha。若按规范 dispose 到该背景色,透明底会被整块铺白。 // 这里统一以透明画布合成,保留帧 alpha。 const canvas = document.createElement('canvas'); canvas.width = canvasW; canvas.height = canvasH; const ctx = canvas.getContext('2d', { willReadFrequently: true, alpha: true }); const bgB = animBgColor & 0xFF; const bgG = (animBgColor >> 8) & 0xFF; const bgR = (animBgColor >> 16) & 0xFF; const bgA = (animBgColor >> 24) & 0xFF; // 仅当背景色本身带透明(A < 255)时才铺底;不透明白/黑一律当透明画布 const useOpaqueBg = bgA > 0 && bgA < 255; if (bgA === 255) { console.log(`[convertAnimatedWebpToApng] ANIM 背景色 rgba(${bgR},${bgG},${bgB},${bgA}) 为不透明,改用透明画布以保留透明底`); } ctx.clearRect(0, 0, canvasW, canvasH); if (useOpaqueBg) { ctx.fillStyle = `rgba(${bgR},${bgG},${bgB},${bgA / 255})`; ctx.fillRect(0, 0, canvasW, canvasH); } const frames = []; let firstError = null; for (let fi = 0; fi < anmfFrames.length; fi++) { const frame = anmfFrames[fi]; const frameWebpBytes = _wrapBitstreamAsWebp(frame.bitstream, frame.w, frame.h); let bitmap; try { bitmap = await _decodeSingleFrameWebp(frameWebpBytes); } catch (e) { if (!firstError) firstError = e; console.warn(`[convertAnimatedWebpToApng] 帧${fi}解码失败: ${frame.w}x${frame.h} @(${frame.x},${frame.y})`, e && e.message); continue; } // blending: 0 = alpha blend (OVER), 1 = no blend (SOURCE/replace) if (frame.blending === 1) { ctx.clearRect(frame.x, frame.y, frame.w, frame.h); } ctx.drawImage(bitmap, frame.x, frame.y); if (typeof bitmap.close === 'function') bitmap.close(); // 捕获全画布 RGBA(含 alpha) const imgData = ctx.getImageData(0, 0, canvasW, canvasH); frames.push({ rgba: new Uint8Array(imgData.data), delay: frame.duration }); // disposal: 1 = restore to background → 透明画布场景下恢复为透明 if (frame.disposal === 1) { ctx.clearRect(frame.x, frame.y, frame.w, frame.h); if (useOpaqueBg) { ctx.fillStyle = `rgba(${bgR},${bgG},${bgB},${bgA / 255})`; ctx.fillRect(frame.x, frame.y, frame.w, frame.h); } } } if (frames.length === 0) { throw new Error('所有动画帧解码失败' + (firstError && firstError.message ? `(${firstError.message})` : '')); } console.log(`[convertAnimatedWebpToApng] 解码完成: ${frames.length}/${anmfFrames.length}帧`); // 逐帧渲染为 PNG 字节 const framePngs = []; for (const f of frames) { const tmpCanvas = document.createElement('canvas'); tmpCanvas.width = canvasW; tmpCanvas.height = canvasH; const tmpCtx = tmpCanvas.getContext('2d'); tmpCtx.putImageData(new ImageData(new Uint8ClampedArray(f.rgba), canvasW, canvasH), 0, 0); tmpCtx.fillStyle = 'rgba(0,0,0,0.004)'; tmpCtx.fillRect(canvasW - 1, canvasH - 1, 1, 1); const pngBlob = await new Promise((resolve, reject) => { tmpCanvas.toBlob((b) => b ? resolve(b) : reject(new Error('帧 PNG 编码失败')), 'image/png'); }); framePngs.push(new Uint8Array(await pngBlob.arrayBuffer())); } // 提取 IHDR 和 IDAT,组装 APNG const firstChunks = _apngReadPngChunks(framePngs[0]); const ihdrChunk = firstChunks.find(c => c.type === 'IHDR'); if (!ihdrChunk) throw new Error('第一帧 PNG 缺少 IHDR'); const allFrameIdats = framePngs.map(png => { const chunks = _apngReadPngChunks(png); return chunks.filter(c => c.type === 'IDAT').map(c => c.data); }); const delays = frames.map(f => f.delay); const apngBuf = _apngBuild(ihdrChunk.data, allFrameIdats, delays, 0); console.log(`[convertAnimatedWebpToApng] APNG 组装完成: ${(apngBuf.byteLength / 1024).toFixed(1)}KB, ${frames.length}帧`); return new File([apngBuf], file.name.replace(/\.webp$/i, '.apng.png'), { type: 'image/png', lastModified: Date.now() }); } // ✅ APNG 压缩(apng-js Player 合成 + UPNG 重编码) // 支持增量帧(blend/dispose),不再降级为静态压缩 async function compressApngToSize(file, maxSizeKB = 2048, options = {}) { const maxBytes = maxSizeKB * 1024; const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; if (file.size <= maxBytes) return file; const arrayBuffer = await file.arrayBuffer(); // 用 apng-js 解析(它能正确处理所有 APNG 结构) // apng-js UMD 导出名为 "apng-js",需要从全局获取 const _apngRaw = window['apng-js'] || window['apngJs']; const apngJsLib = typeof _apngRaw === 'function' ? _apngRaw : (_apngRaw && typeof _apngRaw.default === 'function') ? _apngRaw.default : null; if (!apngJsLib) { console.warn('[compressApngToSize] apng-js 库未加载 (window["apng-js"]=' + typeof _apngRaw + '),降级到静态压缩'); toast('APNG 解析库未加载,将转为静态图片压缩(动画会丢失)', 3000); return compressImageToSize(file, maxSizeKB); } let apng; try { apng = apngJsLib(arrayBuffer); } catch (e) { console.warn('[compressApngToSize] parseAPNG 失败,降级到静态压缩:', e.message); toast('APNG 解码失败,将转为静态图片压缩(动画会丢失)', 0, { type: 'error' }); return compressImageToSize(file, maxSizeKB); } if (apng instanceof Error) { console.warn('[compressApngToSize] parseAPNG 返回错误:', apng.message); toast('APNG 解码失败,将转为静态图片压缩(动画会丢失)', 0, { type: 'error' }); return compressImageToSize(file, maxSizeKB); } const { width, height } = apng; if (!apng.frames || apng.frames.length <= 1) { return compressImageToSize(file, maxSizeKB); } console.log(`[compressApngToSize] APNG: ${width}x${height}, ${apng.frames.length}帧, 总时长=${apng.playTime}ms`); // ---- 第一步:用 apng-js Player 逐帧合成,拿到每帧的完整 RGBA 像素 ---- await apng.createImages(); const renderCanvas = document.createElement('canvas'); renderCanvas.width = width; renderCanvas.height = height; const renderCtx = renderCanvas.getContext('2d', { willReadFrequently: true }); const player = await apng.getPlayer(renderCtx, false); const compositedFrames = []; // { data: Uint8ClampedArray, delay: number } for (let i = 0; i < apng.frames.length; i++) { await player.renderNextFrame(); const imgData = renderCtx.getImageData(0, 0, width, height); compositedFrames.push({ data: new Uint8Array(imgData.data), delay: apng.frames[i].delay || 100 }); } console.log(`[compressApngToSize] 合成完成: ${compositedFrames.length}帧`); // 验证合成帧数据完整性 const expectedFrameBytes = width * height * 4; for (let i = 0; i < compositedFrames.length; i++) { if (compositedFrames[i].data.length !== expectedFrameBytes) { throw new Error(`合成帧${i}数据异常: 期望${expectedFrameBytes}字节, 实际${compositedFrames[i].data.length}字节`); } } console.log(`[compressApngToSize] 帧数据验证通过: ${compositedFrames.length}帧 × ${(expectedFrameBytes/1024).toFixed(0)}KB/帧`); // ---- 第二步:可选抽帧(帧数 > 15 且压缩压力大时启用)---- let workFrames = compositedFrames; let workDelays = compositedFrames.map(f => f.delay); const originalFrameCount = compositedFrames.length; const oversizeRatio = file.size / maxBytes; if (oversizeRatio > 1.8 && compositedFrames.length > 15) { const step = oversizeRatio > 3 ? 3 : 2; const decimated = []; const decimatedDelays = []; for (let i = 0; i < compositedFrames.length; i += step) { decimated.push(compositedFrames[i]); let accumulatedDelay = 0; for (let j = i; j < Math.min(i + step, compositedFrames.length); j++) { accumulatedDelay += compositedFrames[j].delay; } decimatedDelays.push(accumulatedDelay); } workFrames = decimated; workDelays = decimatedDelays; console.log(`[compressApngToSize] 抽帧: ${originalFrameCount} → ${workFrames.length}帧 (step=${step})`); } // ---- 第三步:逐帧 canvas.toBlob + 手动组装 APNG ---- // 完全绕过 UPNG.encode 的多帧模式,避免大数据量内存爆炸 // PNG chunk 读取辅助 const readPngChunks = (u8) => { const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength); let pos = 8; // 跳过 PNG signature const chunks = []; while (pos + 8 <= u8.length) { const len = dv.getUint32(pos); pos += 4; const type = String.fromCharCode(u8[pos], u8[pos+1], u8[pos+2], u8[pos+3]); pos += 4; if (pos + len + 4 > u8.length) break; chunks.push({ type, data: u8.slice(pos, pos + len) }); pos += len + 4; // data + crc } return chunks; }; // 手动组装 APNG const buildApng = (ihdrData, frameChunks, delays, numPlays) => { // 计算总大小 // 第 1 帧: fcTL(38) + IDAT(12+len)* // 后续帧: fcTL(38) + fdAT(12+4+len)* (fdAT 多 4 字节 seqNum) let totalSize = 8 + (12 + 13) + (12 + 8); // sig + IHDR + acTL for (let i = 0; i < frameChunks.length; i++) { totalSize += 12 + 26; // fcTL for (const idat of frameChunks[i]) { totalSize += (i === 0 ? 12 : 12 + 4) + idat.length; // IDAT vs fdAT } } totalSize += 12; // IEND const buf = new ArrayBuffer(totalSize); const u8 = new Uint8Array(buf); const dv = new DataView(buf); let o = 0; // PNG signature u8.set([137, 80, 78, 71, 13, 10, 26, 10], 0); o = 8; // IHDR const ihdrLen = 13; dv.setUint32(o, ihdrLen); o += 4; u8.set([73, 72, 68, 82], o); o += 4; // "IHDR" u8.set(ihdrData, o); o += ihdrLen; // CRC32 of type+data const ihdrCrc = crc32(u8.slice(o - 4 - ihdrLen, o)); dv.setUint32(o, ihdrCrc); o += 4; // acTL (animation control) dv.setUint32(o, 8); o += 4; // length u8.set([97, 99, 84, 76], o); o += 4; // "acTL" dv.setUint32(o, frameChunks.length); o += 4; // num_frames dv.setUint32(o, numPlays); o += 4; // num_plays (0 = infinite) const acTlCrc = crc32(u8.slice(o - 4 - 8, o)); dv.setUint32(o, acTlCrc); o += 4; // Frames — APNG 规范:第 1 帧用 IDAT,后续帧用 fdAT let seqNum = 0; for (let i = 0; i < frameChunks.length; i++) { const w = dv.getUint32(16); // from IHDR const h = dv.getUint32(20); const delay = delays[i] || 100; // fcTL (frame control) — 26 bytes data dv.setUint32(o, 26); o += 4; u8.set([102, 99, 84, 76], o); o += 4; // "fcTL" dv.setUint32(o, seqNum++); o += 4; // sequence_number dv.setUint32(o, w); o += 4; dv.setUint32(o, h); o += 4; dv.setUint32(o, 0); o += 4; // x_offset dv.setUint32(o, 0); o += 4; // y_offset dv.setUint16(o, delay); o += 2; // delay_num dv.setUint16(o, 1000); o += 2; // delay_den (毫秒) // 全画布预合成帧:每帧都是完整 RGBA,必须 SOURCE 替换,不能 OVER 叠透明 u8[o++] = 0; // dispose_op: NONE u8[o++] = 0; // blend_op: SOURCE(0=替换,保留本帧透明区) dv.setUint32(o, crc32(u8.slice(o - 4 - 26, o))); o += 4; if (i === 0) { // 第 1 帧:IDAT(标准 PNG 图像数据块,无额外 seqNum) for (const idatData of frameChunks[i]) { dv.setUint32(o, idatData.length); o += 4; u8.set([73, 68, 65, 84], o); o += 4; // "IDAT" u8.set(idatData, o); o += idatData.length; dv.setUint32(o, crc32(u8.slice(o - 4 - idatData.length, o))); o += 4; } } else { // 后续帧:fdAT(frame data,比 IDAT 多 4 字节 seqNum) for (const idatData of frameChunks[i]) { const fdatPayloadLen = 4 + idatData.length; // seqNum + 压缩数据 dv.setUint32(o, fdatPayloadLen); o += 4; u8.set([102, 100, 65, 84], o); o += 4; // "fdAT" dv.setUint32(o, seqNum++); o += 4; // sequence_number u8.set(idatData, o); o += idatData.length; // CRC 覆盖 type("fdAT") + payload(seqNum + data) dv.setUint32(o, crc32(u8.slice(o - 4 - idatData.length - 4, o))); o += 4; } } } // IEND dv.setUint32(o, 0); o += 4; u8.set([73, 69, 78, 68], o); o += 4; dv.setUint32(o, crc32(u8.slice(o - 4, o))); o += 4; return buf; }; // CRC32 查找表 const crcTable = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } table[n] = c; } return table; })(); const crc32 = (data) => { let crc = 0xFFFFFFFF; for (let i = 0; i < data.length; i++) { crc = crcTable[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8); } return (crc ^ 0xFFFFFFFF) >>> 0; }; // 逐帧渲染到 canvas + toBlob const renderFrameToBlob = (srcData, srcW, srcH, scale) => { return new Promise((resolve, reject) => { const newW = Math.max(1, Math.floor(srcW * scale)); const newH = Math.max(1, Math.floor(srcH * scale)); const canvas = document.createElement('canvas'); canvas.width = newW; canvas.height = newH; const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; // 通过 Image → canvas 方式绘制(避免 OffscreenCanvas + putImageData 的内存问题) const img = new Image(); const blob = new Blob([srcData], { type: 'image/bmp' }); const url = URL.createObjectURL(blob); img.onload = () => { ctx.drawImage(img, 0, 0, newW, newH); URL.revokeObjectURL(url); canvas.toBlob((pngBlob) => { if (!pngBlob) { reject(new Error('toBlob 返回 null')); return; } pngBlob.arrayBuffer().then(ab => resolve(new Uint8Array(ab))).catch(reject); }, 'image/png'); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image 加载失败')); }; img.src = url; }); }; // 单帧编码:canvas 缩放 → toBlob('image/png') // 在右下角放一个几乎透明的像素,强制浏览器输出 RGBA(type=6),避免偷换成 RGB const renderFrameToPng = (rgbaData, srcW, srcH, scale) => { return new Promise((resolve, reject) => { const newW = Math.max(1, Math.floor(srcW * scale)); const newH = Math.max(1, Math.floor(srcH * scale)); const srcCanvas = document.createElement('canvas'); srcCanvas.width = srcW; srcCanvas.height = srcH; const srcCtx = srcCanvas.getContext('2d'); srcCtx.putImageData(new ImageData(new Uint8ClampedArray(rgbaData), srcW, srcH), 0, 0); const dstCanvas = document.createElement('canvas'); dstCanvas.width = newW; dstCanvas.height = newH; const dstCtx = dstCanvas.getContext('2d'); dstCtx.imageSmoothingEnabled = true; dstCtx.imageSmoothingQuality = 'high'; dstCtx.drawImage(srcCanvas, 0, 0, newW, newH); // 强制 RGBA:在不可见角落放一个几乎全透明的像素 dstCtx.fillStyle = 'rgba(0,0,0,0.004)'; dstCtx.fillRect(newW - 1, newH - 1, 1, 1); dstCanvas.toBlob((pngBlob) => { if (!pngBlob) { reject(new Error('toBlob 返回 null')); return; } pngBlob.arrayBuffer().then(ab => resolve(new Uint8Array(ab))).catch(reject); }, 'image/png'); }); }; const targetLowerBytes = Math.floor((maxSizeKB - 68) * 1024); const startTime = performance.now(); // 缩放梯度:从大到小(直接枚举,避免浮点死循环);超限时按实测体积跳档 const scaleSteps = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.15]; for (let stepIndex = 0; stepIndex < scaleSteps.length; stepIndex++) { const scale = scaleSteps[stepIndex]; const newW = Math.max(1, Math.floor(width * scale)); const newH = Math.max(1, Math.floor(height * scale)); console.log(`[compressApngToSize] 尝试 scale=${scale.toFixed(2)}: ${newW}x${newH}, ${workFrames.length}帧`); try { // 逐帧渲染为 PNG const framePngs = []; for (let i = 0; i < workFrames.length; i++) { const pngU8 = await renderFrameToPng(workFrames[i].data, width, height, scale); framePngs.push(pngU8); } // 解析第一帧的 IHDR const firstChunks = readPngChunks(framePngs[0]); const ihdrChunk = firstChunks.find(c => c.type === 'IHDR'); if (!ihdrChunk) throw new Error('第一帧 PNG 缺少 IHDR'); // 提取每帧的 IDAT 数据 const allFrameIdats = framePngs.map(png => { const chunks = readPngChunks(png); return chunks.filter(c => c.type === 'IDAT').map(c => c.data); }); // 组装 APNG const apngBuf = buildApng(ihdrChunk.data, allFrameIdats, workDelays, 0); const blob = new Blob([apngBuf], { type: 'image/apng' }); const sizeKB = blob.size / 1024; console.log(`[compressApngToSize] scale=${scale.toFixed(2)} → ${sizeKB.toFixed(1)}KB`); if (onProgress) { onProgress({ scale, sizeKB, originalKB: file.size / 1024, frameCount: workFrames.length, newW: Math.floor(width * scale), newH: Math.floor(height * scale) }); } if (blob.size >= targetLowerBytes && blob.size <= maxBytes) { const elapsed = ((performance.now() - startTime) / 1000).toFixed(2); console.log(`[compressApngToSize] ✓ 命中目标: ${sizeKB.toFixed(1)}KB, ${elapsed}秒`); return new File([blob], file.name.replace(/\.\w+$/i, '') + '-compressed.png', { type: 'image/png' }); } if (blob.size <= maxBytes) { // 低于上限但未命中下限——也接受 const elapsed = ((performance.now() - startTime) / 1000).toFixed(2); console.log(`[compressApngToSize] ✓ 完成: ${(file.size/1024).toFixed(1)}KB → ${sizeKB.toFixed(1)}KB, ${elapsed}秒`); return new File([blob], file.name.replace(/\.\w+$/i, '') + '-compressed.png', { type: 'image/png' }); } // 仍超限:按 scale² 预估下一档,跳过中间无效梯度 if (stepIndex < scaleSteps.length - 1) { const predicted = scale * Math.sqrt(maxBytes / Math.max(blob.size, 1)) * 0.92; let jumpTo = stepIndex + 1; for (let j = stepIndex + 1; j < scaleSteps.length; j++) { jumpTo = j; if (scaleSteps[j] <= predicted + 1e-6) break; } if (jumpTo > stepIndex + 1) { console.log(`[compressApngToSize] 跳档: ${scale.toFixed(2)} (${sizeKB.toFixed(1)}KB) 预估=${predicted.toFixed(3)} → ${scaleSteps[jumpTo].toFixed(2)}`); stepIndex = jumpTo - 1; } } } catch (e) { console.error(`[compressApngToSize] scale=${scale.toFixed(2)} 失败:`, e.message); if (onProgress) { onProgress({ scale, error: e.message, originalKB: file.size / 1024 }); } } } throw new Error('APNG 压缩失败:所有缩放比例均无法降到限制以内,请手动处理'); } function interceptReplyForm() { // —— 缓存工具(GM_* 优先;localStorage 兜底;返回对象型默认值) —— function cacheGet(key, fallback = null) { try { if (typeof GM_getValue === 'function') { const v = GM_getValue(key); return v != null ? v : fallback; } const s = localStorage.getItem(key); return s != null ? JSON.parse(s) : fallback; } catch (_) { return fallback; } } function cacheSet(key, val) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, val); return; } localStorage.setItem(key, JSON.stringify(val)); } catch (_) {} } // —— NFKC 候选池 —— function getNkfcBase() { const CACHE_KEY_NKFC = 'nkfcBase.v2'; let base = cacheGet(CACHE_KEY_NKFC, {}); if (Object.keys(base).length > 0) return base; base = {}; for (let i = 0; i <= 0xFFFF; i++) { const ch = String.fromCharCode(i); const norm = ch.normalize('NFKC'); if (ch !== norm) (base[norm] ||= []).push(ch); } cacheSet(CACHE_KEY_NKFC, base); return base; } // —— 单字符替换 —— // —— 颜文字中常见的汉字与英文,替换时排除(可保留或按需维护) —— const KAOMOJI_EXCLUDE_CHARS = new Set([ '旦','开','摆','摔','低','好','钩','我','咬','接','龙','大','成','功', '举','高','糕','咩','吁','肥','喵','酱','狗','比','汪','哈','電','柱', 'N','o','O','o' ]); // —— 单字符替换(回归稳定候选:优先第一个),但保留缓存为数组以便未来扩展 —— function maskChar(ch, skipAscii = true) { const CACHE_KEY_CHARMAP = 'unvcodeCharMap.v3'; const charMap = cacheGet(CACHE_KEY_CHARMAP, {}); if (charMap.hasOwnProperty(ch)) { const cached = charMap[ch]; if (Array.isArray(cached) && cached.length > 0) { return cached[0]; // 稳定使用首选候选,旧版风格 } else if (typeof cached === 'string') { return cached; } } if (skipAscii && ch.charCodeAt(0) < 128) { charMap[ch] = [ch]; cacheSet(CACHE_KEY_CHARMAP, charMap); return ch; } const base = getNkfcBase(); const norm = ch.normalize('NFKC'); const candidates = base[norm] || []; const mapped = candidates.length > 0 ? candidates[0] : ch; charMap[ch] = [mapped]; cacheSet(CACHE_KEY_CHARMAP, charMap); return mapped; } // —— 逐字替换(委托 maskChar) —— function unvcode(text, skipAscii = true) { let out = ''; for (const ch of text) out += maskChar(ch, skipAscii); return out; } // —— 保留你之前的选择性规则:URL 整段跳过;非 URL 部分中文 → unvcode;英文 → 原样 + U+200B;其他原样 —— function unvcodeSelective(text) { const urlRegex = /(?:(?:https?|ftp):\/\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}(?:\/[^\s]*)?|>>(?:No\.)?\d+)/gi; const hanRegex = /[\u4E00-\u9FFF]/; const engRegex = /[A-Za-z]/; return text .split(/(\b(?:https?|ftp):\/\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}(?:\/[^\s]*)?|>>(?:No\.)?\d+)/gi) .map(part => { if (!part) return ''; // URL 整段跳过 if (urlRegex.test(part)) { urlRegex.lastIndex = 0; return part; } // 非 URL:按规则处理 let out = ''; for (const ch of part) { if (hanRegex.test(ch) && !KAOMOJI_EXCLUDE_CHARS.has(ch)) { out += unvcode(ch, false); // 中文 → unvcode } else if (engRegex.test(ch) && !KAOMOJI_EXCLUDE_CHARS.has(ch)) { out += ch + '\u200B'; // 英文 → 原样 + U+200B } else { out += ch; // 其他 → 原样 } } return out; }) .join(''); } // —— 根据当前失败文本,精准刷新需处理字符的缓存 —— // 仅刷新:非 URL 段中的中文与英文,且不在排除集合中的字符 function resetCacheForFailedContent(text) { const urlRegex = /(?:(?:https?|ftp):\/\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}(?:\/[^\s]*)?|>>(?:No\.)?\d+)/gi; const hanRegex = /[\u4E00-\u9FFF]/; const engRegex = /[A-Za-z]/; const CACHE_KEY_CHARMAP = 'unvcodeCharMap.v3'; const charMap = cacheGet(CACHE_KEY_CHARMAP, {}); const parts = text.split(/(\b(?:https?|ftp):\/\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}(?:\/[^\s]*)?|>>(?:No\.)?\d+)/gi); let changed = false; for (const part of parts) { if (!part) continue; // 跳过 URL 段 if (urlRegex.test(part)) { urlRegex.lastIndex = 0; continue; } // 非 URL 段:逐字检查 for (const ch of part) { const isHan = /[\u4E00-\u9FFF]/.test(ch); const isEng = /[A-Za-z]/.test(ch); if (!isHan && !isEng) continue; if (KAOMOJI_EXCLUDE_CHARS.has(ch)) continue; // 清空该字符的缓存(使下次重新生成替换) if (charMap[ch]) { delete charMap[ch]; changed = true; } } } if (changed) cacheSet(CACHE_KEY_CHARMAP, charMap); } // done 可选unvcode模式或者零宽空格模式,目前来看unvcode模式下长文本中被替换的文字较多,观感受影响,只要没有复制需求,零宽空格更实用 // done BUG 其他报错似乎不toast提示-大概是好了吧,新增了500报错的toast,但目前图床有问题没法确定图片安全性审核不通过是不是也会toast // —— 表单提交拦截 —— document.addEventListener('submit', function (e) { const form = e.target; const isReply = form.matches('form[action="/Home/Forum/doReplyThread.html"]'); const isPost = form.matches('form[action="/Home/Forum/doPostThread.html"]'); if (!isReply && !isPost) return; e.preventDefault(); const formData = new FormData(form); // 文字内容 let content = (formData.get('content') || '').toString().trim(); // 新增:如果内容只有 "0",就在后面加一个零宽空格 if (content === '0') { content = '0\u200B'; // 在 0 后追加零宽空格 formData.set('content', content); const textarea = form.querySelector('textarea[name="content"]'); if (textarea) textarea.value = content; } if (!content) { // 检查是否选择了图片(支持 name="image") const fileInput = form.querySelector('input[type="file"][name="image"]'); const hasImage = !!(fileInput && fileInput.files && fileInput.files.length > 0); if (hasImage || content === '0') { //无文字但有图片,或者内容只有 "0" → 自动补零宽空格,并同步到 FormData 与 DOM //修改以自定义。 //content = '分享图片';//默认占位文字 //content = '';//空格占位符 U+200E //content = ' ';//全角空格占位符 U+3000 content = ''; // 零宽空格占位符 U+200B formData.set('content', content); const textarea = form.querySelector('textarea[name="content"]'); if (textarea) textarea.value = content; } else { toast(isReply ? '回复内容不能为空' : '发串内容不能为空'); return; } } // 检查错误信息是否与图片大小有关 function isImageSizeError(msg) { if (!msg) return false; const patterns = [ /图片.*大/i, /不能超过.*2048/i, /超过.*2048/i, /2048.*kb/i, /文件.*大/i, /image.*size/i, /file.*large/i, /too\s*large/i ]; return patterns.some(p => p.test(msg)); } function resetIllegalRetryState(options = {}) { const { clearOriginalContent = false } = options; form.__illegalRetryCount = 0; form.__illegalRetryCountU200B = 0; if (clearOriginalContent) { form.__originalContent = null; } } function processTextPreservingHiddenTags(input, processor) { if (!input || typeof processor !== 'function') return input || ''; return input .split(/(\[h\]|\[\/h\])/gi) .map(part => (/^\[h\]$|^\[\/h\]$/i.test(part) ? part : processor(part))) .join(''); } function classifySubmitResponse(doc, html) { const successMsg = doc.querySelector('p.success'); const errorMsg = doc.querySelector('p.error'); if (successMsg) return { kind: 'success', node: successMsg }; if (errorMsg) return { kind: 'error', node: errorMsg }; try { if (/500\s+Internal\s+Server\s+Error/i.test(html) || html.indexOf('<title>500 Internal Server Error</title>') !== -1) { return { kind: 'fallback-error', message: '500 Internal Server Error,可能是图床故障' }; } const errorDiv = doc.querySelector('div.error'); const h1 = errorDiv && errorDiv.querySelector('h1'); if (h1 && h1.textContent.trim()) { return { kind: 'fallback-error', message: h1.textContent.trim() }; } const titleEl = doc.querySelector('title'); if (titleEl && /系统发生错误|系统错误/i.test(titleEl.textContent)) { return { kind: 'fallback-error', message: '系统发生错误' }; } } catch (e) { console.warn('[interceptReplyForm] error while checking fallback formats:', e); } return { kind: 'unknown' }; } function classifySubmitError(msg) { if (isImageSizeError(msg)) return 'image-size'; if (/格式不正确/.test(msg)) return 'image-format'; if (/非法图像/.test(msg)) return 'illegal-image'; if (/含有非法词语/.test(msg)) return 'illegal-word'; return 'other'; } function isSubmitNetworkError(err) { // fetch 抛 TypeError = 网络层失败(断网/连接重置/CORS 等),不是超时; // 真正的超时由 doSubmit 的 AbortController 处理(err.name === 'AbortError')。 // 请求可能已到达服务器(如响应途中断连),因此调用方按「结果未知」处理。 return !!(err && err.name === 'TypeError' && /fetch|network|load failed/i.test(err.message || '')); } function formatSubmitError(err) { return '发送失败:' + ((err && err.message) || '未知错误'); } function clearSuccessfulSubmitForm() { const textarea = form.querySelector('textarea[name="content"]'); if (textarea) { textarea.value = ''; textarea.dispatchEvent(new Event('input', { bubbles: true })); } const fileInput = form.querySelector('input[type="file"][name="image"]'); if (fileInput) { fileInput.value = ''; // 附件随发送清空:同步复位 HEIC 预览去重状态,避免下一条同文件不再提示 if (typeof updatePreviewImageFromFile === 'function') updatePreviewImageFromFile(null); } form.__preparedImage = null; deleteDraftSafe(getDraftKey()); const titleInput = form.querySelector('input[name="title"]'); if (titleInput) { titleInput.value = ''; titleInput.dispatchEvent(new Event('input', { bubbles: true })); } const nameInput = form.querySelector('input[name="name"]'); if (nameInput) { nameInput.value = ''; nameInput.dispatchEvent(new Event('input', { bubbles: true })); } const emailInput = form.querySelector('input[name="email"]'); if (emailInput) { emailInput.value = ''; emailInput.dispatchEvent(new Event('input', { bubbles: true })); } document.dispatchEvent(new CustomEvent('replySuccess', { detail: { key: getDraftKey(), tid: form.querySelector('input[name="resto"]')?.value || '' } })); } function runSuccessfulSubmitNavigation(confirmPromise) { if (isReply) { // 板块页/时间线页:跳过全量刷新,由 handleBoardQuickReplyRefresh 做增量更新 // 串内页:正常全量刷新 const _isBoardOrTimeline = PageType.isBoardOrTimelinePage(); if (!_isBoardOrTimeline) { try { // 错峰 150ms 发起:避开发送后发言历史回查(getLastPost/ref/thread api)的并发峰值 setTimeout(() => { refreshRepliesWithSeamlessPaging((result) => { const ok = !(result && result.ok === false); // 刷新完成(翻页逻辑已在内部处理) recordCurrentThreadHistory(0, { reason: 'reply-success-refresh', countVisit: false, touchVisitedAt: true }); if (ok) { console.log('回复区刷新完成'); } else { console.warn('回复区刷新失败', result && result.reason); } }, { getConfig: safeGetConfig }); }, 150); } catch (err) { console.error('refreshRepliesWithSeamlessPaging 调用失败', err); } } return; } // 发串:根据设置决定行为 const cfg = (typeof SettingPanel !== 'undefined' && SettingPanel.state) ? SettingPanel.state : {}; const postAction = cfg.postAfterAction === 'refresh' ? 'refresh' : 'jump'; if (postAction === 'jump' && confirmPromise) { toast('新串发送成功,正在确认地址……'); Promise.race([ confirmPromise, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), POST_HISTORY_CONFIRM_TIMEOUT_MS)) ]).then(confirmed => { if (confirmed && confirmed.url) { window.open(confirmed.url, '_blank'); toast('已在新标签页打开新串'); } else { toast('新串已发送,但未能确认地址', 0, { type: 'error' }); } }).catch(() => { toast('新串已发送,确认地址超时', 0, { type: 'error' }); }); } else if (postAction === 'refresh' && confirmPromise) { // refresh 模式:等待发言历史确认后,跳转板块第一页顶部 toast('新串发布成功,正在确认地址……'); Promise.race([ confirmPromise, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), POST_HISTORY_CONFIRM_TIMEOUT_MS)) ]).then(() => { const boardPage1 = location.origin + location.pathname + '?page=1'; toast('正在转版块第一页……'); window.location.href = boardPage1; }).catch(() => { const boardPage1 = location.origin + location.pathname + '?page=1'; toast('确认地址超时,正在转版块第一页……'); window.location.href = boardPage1; }); } else { // jump 模式但无 confirmPromise:直接刷新页面 location.reload(); } } // 发送前处理状态:复用「发送中」状态 toast 节点(key=send-status)。 // 处理期间长驻留并原地更新,完成/失败时替换或清除,避免用户误以为提交卡住。 function showSubmitPreflightStatus(msg, duration = 600000) { try { toast(msg, duration, { queue: false, key: 'send-status' }); } catch (e) { console.warn('[interceptReplyForm] 发送前状态提示失败:', e); } } function clearSubmitPreflightStatus() { try { const $t = $('#xdex-immediate-toast-send-status'); if ($t.length) { $t.stop(true, false); $t.fadeOut(120, () => $t.remove()); } } catch (e) { /* 忽略清理失败 */ } } // 服务端拒绝后的本地重试处理(转换/压缩/GIF 修复)统一 wall-clock 上限。 // 超时后调用方的 await 立即 reject;迟到的结果不会被使用,内部 notify 需自检 token。 function runImageProcessingWithTimeout(label, operation, timeoutHint = '请更换图片,或手动压缩/转换后重试。') { const token = { cancelled: false }; let timer = 0; const seconds = Math.round(SUBMIT_IMAGE_PROCESSING_TIMEOUT_MS / 1000); const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { token.cancelled = true; const err = new Error(`${label}超时(${seconds} 秒),本次未发送。${timeoutHint}`); err.__imageProcessingTimeout = true; reject(err); }, SUBMIT_IMAGE_PROCESSING_TIMEOUT_MS); }); return Promise.race([ Promise.resolve().then(() => operation(token)), timeoutPromise ]).finally(() => clearTimeout(timer)); } async function compressImageForRetry(file, actualFormat, notify = toast) { if (actualFormat === 'gif') { let gifFile = file; if (!/\.gif$/i.test(file.name)) { const fixedName = file.name.replace(/\.\w+$/i, '.gif'); gifFile = new File([file], fixedName, { type: 'image/gif', lastModified: file.lastModified }); console.log(`[interceptReplyForm] 后缀已修正: ${file.name} → ${fixedName}`); } const gifResult = await compressGifToSize(gifFile, { maxSizeKB: 2048, targetLowerKB: 1850, acceptableLowerKB: 1780, onProgress: (progress) => { if (progress.error) { notify(`GIF压缩中:第${progress.index}/${progress.total}次失败,继续尝试……`, 1800); return; } notify(`GIF压缩中:第${progress.index}/${progress.total}次,${progress.originalKB.toFixed(1)}KB → ${progress.currentKB.toFixed(1)}KB`, 1800); } }); console.log(`[interceptReplyForm] GIF压缩完成: ${gifResult.summary}`); const compressedFile = gifResult.file; try { Object.defineProperty(compressedFile, '__xdexGifCompressionDurationMs', { value: gifResult.durationMs, enumerable: false, configurable: true }); } catch (metadataError) { console.warn('[X岛-EX][GIF压缩] 无法附加耗时 metadata:', metadataError); } return compressedFile; } if (actualFormat === 'apng') { const compressedFile = await compressApngToSize(file, 2048, { onProgress: (progress) => { if (progress.error) { notify(`APNG压缩中:scale=${progress.scale.toFixed(2)} 失败,继续尝试……`, 1800); return; } notify(`APNG压缩中:${progress.newW}x${progress.newH} ${progress.frameCount}帧 → ${progress.sizeKB.toFixed(0)}KB(原${progress.originalKB.toFixed(0)}KB)`, 1800); } }); console.log(`[interceptReplyForm] APNG压缩完成: ${(compressedFile.size / 1024).toFixed(1)}KB`); return compressedFile; } if (actualFormat === 'animated-webp') { notify('检测到动画 WebP,正在转换为 APNG 保留动画……', 2000); // 先转 APNG(保留动画),再按需压缩 let convertedFile = await convertWebpToAcceptedFormat(file, true); console.log(`[interceptReplyForm] 动画WebP→APNG: ${(file.size / 1024).toFixed(1)}KB -> ${(convertedFile.size / 1024).toFixed(1)}KB`); if (convertedFile.size > SUBMIT_IMAGE_LIMIT_BYTES) { console.log(`[interceptReplyForm] 转换后 ${(convertedFile.size / 1024).toFixed(1)}KB 仍超限,用 APNG 压缩`); notify(`转换后 ${(convertedFile.size / 1024).toFixed(1)}KB 仍超限,正在压缩动画……`, 0, { type: 'error' }); const compressResult = await compressApngToSize(convertedFile, 2048, { onProgress: (progress) => { if (progress.error) { notify(`APNG压缩中:scale=${progress.scale.toFixed(2)} 失败,继续尝试……`, 1800); return; } notify(`APNG压缩中:${progress.newW}x${progress.newH} ${progress.frameCount}帧 → ${progress.sizeKB.toFixed(0)}KB(原${progress.originalKB.toFixed(0)}KB)`, 1800); } }); convertedFile = compressResult.file || compressResult; console.log(`[interceptReplyForm] APNG压缩完成: ${(convertedFile.size / 1024).toFixed(1)}KB`); } return convertedFile; } if (actualFormat === 'heic') { notify('检测到 HEIC,正在转换为 PNG……', 2000); let convertedFile = await convertHeicToPng(file); console.log(`[interceptReplyForm] HEIC→PNG: ${(file.size / 1024).toFixed(1)}KB -> ${(convertedFile.size / 1024).toFixed(1)}KB`); if (convertedFile.size > SUBMIT_IMAGE_LIMIT_BYTES) { console.log(`[interceptReplyForm] 转换后 ${(convertedFile.size / 1024).toFixed(1)}KB 仍超限,继续压缩`); convertedFile = await compressImageToSize(convertedFile, 2048, 0.6, 'png'); console.log(`[interceptReplyForm] 压缩完成: ${(convertedFile.size / 1024).toFixed(1)}KB`); } return convertedFile; } const compressedFile = await compressImageToSize(file, 2048, 0.6, actualFormat); console.log(`[interceptReplyForm] 压缩后大小: ${(compressedFile.size / 1024).toFixed(1)}KB`); console.log(`[interceptReplyForm] 静态图片压缩完成: ${(compressedFile.size / 1024).toFixed(1)}KB`); return compressedFile; } // 发送前预检:只在真正要发请求时执行,避免选图即压缩。 // 返回 { fd, prepared };prepared=true 表示图片已在本地处理过,后续错误分支不再重复压缩。 function rememberPreparedSubmitImage(sourceFile, preparedFile) { if (!sourceFile || !preparedFile) return; form.__preparedImage = { name: sourceFile.name, size: sourceFile.size, lastModified: sourceFile.lastModified, file: preparedFile }; } async function prepareImageBeforeSubmit(fd, token) { const isCancelled = () => !!(token && token.cancelled); // 超时后不再更新状态 toast,避免覆盖用户重试后的新状态 const status = (msg, duration) => { if (!isCancelled()) showSubmitPreflightStatus(msg, duration); }; const fileInput = form.querySelector('input[type="file"][name="image"]'); const file = fileInput && fileInput.files && fileInput.files[0]; if (!file) return { fd, prepared: false }; if (!form.__preparedImageChangeBound) { form.__preparedImageChangeBound = true; fileInput.addEventListener('change', () => { form.__preparedImage = null; }); } // 同一张图已处理过(例如非法词重试或用户再次点击发送)直接复用 const cached = form.__preparedImage; if (cached && cached.file && cached.name === file.name && cached.size === file.size && cached.lastModified === file.lastModified) { return { fd: cloneFormDataWithImage(fd, cached.file), prepared: true }; } const actualFormat = await detectImageFormat(file); // 检测阶段也可能被超时取消;此时不得重新上锁或开始压缩 if (isCancelled()) return { fd, prepared: false, cancelled: true }; const mustConvert = actualFormat === 'webp' || actualFormat === 'animated-webp' || actualFormat === 'heic'; const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); const tooLarge = file.size > SUBMIT_IMAGE_LIMIT_BYTES; // 格式必须转换;体积则尊重「自动压缩图片」开关 if (!mustConvert && !(tooLarge && cfg.interceptReplyFormAutoCompress)) { return { fd, prepared: false }; } const sizeKB = (file.size / 1024).toFixed(1); const formatLabel = actualFormat === 'gif' ? ' GIF' : actualFormat === 'apng' ? ' APNG' : ''; let startMsg; if (actualFormat === 'webp') { startMsg = `发送前处理:服务器不接受 WebP,正在转换为 PNG(${sizeKB}KB)……`; } else if (actualFormat === 'animated-webp') { startMsg = `发送前处理:服务器不接受动画 WebP,正在转换为 APNG(${sizeKB}KB)……`; } else if (actualFormat === 'heic') { startMsg = `发送前处理:服务器不接受 HEIC,正在转换为 PNG(${sizeKB}KB)……`; } else { startMsg = `发送前处理:图片 ${sizeKB}KB 超过 2048KB,正在压缩${formatLabel}……`; } status(startMsg); beginSubmitImageProcessing(form); try { // 压缩/转换过程中的进度统一写入同一状态 toast,避免长任务期间提示消失 const notify = (msg) => status(`发送前处理 · ${msg}`); let outFile; if (actualFormat === 'webp') { // 静态 WebP:compressImageForRetry 会输出 WebP,服务器仍拒绝,必须先转 PNG outFile = await convertWebpToAcceptedFormat(file, false); if (outFile.size > SUBMIT_IMAGE_LIMIT_BYTES) { status(`发送前处理:转换后 ${(outFile.size / 1024).toFixed(1)}KB 仍超限,正在压缩……`); outFile = await compressImageForRetry(outFile, 'png', notify); } } else if (mustConvert) { // HEIC / 动画 WebP:compressImageForRetry 内含转换与按需压缩 outFile = await compressImageForRetry(file, actualFormat, notify); } else { outFile = await compressImageForRetry(file, actualFormat, notify); } // 超时后迟到的处理结果:直接丢弃,不写缓存、不发送、不动表单状态 if (isCancelled()) return { fd, prepared: false, cancelled: true }; // 处理期间用户可能换了图:若输入框已不是同一张图,放弃本次处理结果, // 避免「界面显示新图、实际发送旧图」;缓存也不写入。 const latestInput = form.querySelector('input[type="file"][name="image"]'); const latestFile = latestInput && latestInput.files && latestInput.files[0]; if (!latestFile || latestFile.name !== file.name || latestFile.size !== file.size || latestFile.lastModified !== file.lastModified) { throw new Error('发送前处理期间图片已被更换或移除,本次未发送,请重新点击发送'); } rememberPreparedSubmitImage(file, outFile); const gifMs = Number(outFile && outFile.__xdexGifCompressionDurationMs); const gifSeconds = Number.isFinite(gifMs) && gifMs >= 0 ? gifMs / 1000 : null; status(`发送前处理完成:已处理至 ${(outFile.size / 1024).toFixed(1)}KB${gifSeconds == null ? '' : `,耗时 ${gifSeconds.toFixed(2)} 秒`},正在提交……`, 2000); return { fd: cloneFormDataWithImage(fd, outFile), prepared: true }; } finally { // 已超时取消时不要清掉可能属于新一轮预检的 processing 标记 if (!isCancelled()) finishSubmitImageProcessing(form); } } async function doSubmit(fd, isRetry = false, prepared = false) { // 图片处理完成后递归提交时,在保持原提交锁的同时重新开始网络看门狗与真实超时。 if (!isRetry && !prepared) { const token = { cancelled: false }; let preflightTimer = 0; const timeoutPromise = new Promise((_, reject) => { preflightTimer = setTimeout(() => { token.cancelled = true; const timeoutErr = new Error(SUBMIT_IMAGE_PROCESSING_TIMEOUT_MSG); timeoutErr.__preflightTimeout = true; reject(timeoutErr); }, SUBMIT_IMAGE_PROCESSING_TIMEOUT_MS); }); try { const prep = await Promise.race([ prepareImageBeforeSubmit(fd, token), timeoutPromise ]); if (prep && prep.cancelled) return; fd = prep.fd; prepared = prep.prepared; } catch (prepErr) { clearSubmitPreflightStatus(); if (prepErr && prepErr.__preflightTimeout) { // 预检超时:解锁由调用方 finally 负责;迟到的处理结果由 token 丢弃 finishSubmitImageProcessing(form); toast(prepErr.message, 0, { type: 'error', key: 'send-error' }); return; } console.error('[interceptReplyForm] 图片预检失败:', prepErr); toast(prepErr && prepErr.message ? prepErr.message : '图片预处理失败,请手动处理后重试', 0, { type: 'error' }); return; } finally { clearTimeout(preflightTimer); } } // 新请求即将真正发出:旧的「结果未知」由本次结果覆盖 // (放在预检之后,避免预检失败时丢失上一次超时的重复提交警告) form.__submitUnknown = false; finishSubmitImageProcessing(form); refreshSubmitLockTimer(form); const timeoutMs = computeSubmitRequestTimeoutMs(fd); let controller = null; let abortTimer = 0; if (typeof AbortController === 'function') { controller = new AbortController(); form.__submitController = controller; abortTimer = setTimeout(() => { // 期间若已发起新的递归请求,旧计时器不得误杀新请求 if (form.__submitController !== controller) return; form.__submitAbortTimer = 0; form.__submitUnknown = true; try { controller.abort('timeout'); } catch (e) { /* 老内核不支持 reason */ } }, timeoutMs); form.__submitAbortTimer = abortTimer; } // 用 Promise 包一层:fetch 同步抛错时也能进入 .catch,避免状态提示滞留 return Promise.resolve().then(() => fetch(form.action, { method: form.method, body: fd, credentials: 'include', signal: controller ? controller.signal : undefined })) .then(res => res.text()) .then(async html => { // 收到服务器响应 = 结果已确定(成功或业务错误): // 撤销本次请求的看门狗与中止计时器,避免后续图片转换/递归重试期间旧计时器误报。 clearSubmitLockTimer(form); clearTimeout(abortTimer); if (form.__submitAbortTimer === abortTimer) form.__submitAbortTimer = 0; if (form.__submitController === controller) form.__submitController = null; form.__submitUnknown = false; // 响应已到达:结束「发送前处理/正在提交」状态,交给成功或错误提示 clearSubmitPreflightStatus(); // 增加调试输出:打印响应 HTML 的前 2000 字符,避免控制台被大量内容淹没 try { console.log('[interceptReplyForm] response html (truncated):', html.slice(0, 2000)); } catch (e) { console.log('[interceptReplyForm] response html (full):', html); } const doc = new DOMParser().parseFromString(html, 'text/html'); const response = classifySubmitResponse(doc, html); const successMsg = response.kind === 'success' ? response.node : null; const errorMsg = response.kind === 'error' ? response.node : null; // 打印解析到的 success / error 节点,便于调试未触发 toast 的情况 console.log('[interceptReplyForm] parsed successMsg:', successMsg, 'errorMsg:', errorMsg); if (response.kind === 'fallback-error') { toast(response.message, 0, { type: 'error', key: 'send-error' }); return; } if (successMsg) { // 与触底/手动局部刷新一致:快速即时 toast,不走默认队列 toast(successMsg.textContent.trim() || (isReply ? '回复成功' : '发串成功'), 900, { queue: false, key: 'send-status' }); // 服务器明确回执成功 → 本饼干进入 15s 冷却(失败不进入) try { startCookieCooldown(); } catch (e) {} const { confirmPromise, localId } = snapshotSubmittedPostHistory(fd, { isPost, isReply, form }); // 清空输入框、图片、草稿及身份字段,并广播成功事件 clearSuccessfulSubmitForm(); // 重置预览框并刷新预览饼干 ID clearSuccessfulSubmitPreview(); // if (isReply) { // try { // refreshRepliesWithSeamlessPaging(() => { // try { // reapplyPageEnhancements(); // if (window.SeamlessPaging && typeof window.SeamlessPaging.refreshAndMaybeLoadNext === 'function') { // // 延迟一点再检查,避免 DOM 还没稳定 // setTimeout(() => { // try { // window.SeamlessPaging.refreshAndMaybeLoadNext(); // } catch (err) { // console.warn('SeamlessPaging.refreshAndMaybeLoadNext 执行失败', err); // } // }, 200); // } // } catch (err) { // console.warn('刷新后增强处理失败', err); // // 不向上抛出,避免触发外层的 catch // } // }); // } catch (err) { // console.error('refreshRepliesWithSeamlessPaging 调用失败', err); // // 不向上抛出,避免触发外层的 "未知错误" // } // } else { // location.reload(); // } runSuccessfulSubmitNavigation(confirmPromise); } else if (errorMsg) { const msg = errorMsg.textContent.trim() || '提交失败'; // 服务端回执里带「冷却时间[N]s」→ 按服务器给出的剩余秒数校准显示 // (仅在该饼干已有成功计时记录时生效,失败提交本身不创造冷却) try { syncCookieCooldownFromServer(msg); } catch (e) {} const errorKind = classifySubmitError(msg); const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); // 图片格式不被服务器接受(如 WebP):转换为 PNG 后重新提交 if (errorKind === 'image-format' && !isRetry && !prepared) { const fileInput = form.querySelector('input[type="file"][name="image"]'); const file = fileInput && fileInput.files && fileInput.files[0]; if (file) { const actualFormat = await detectImageFormat(file); console.log(`[interceptReplyForm] 格式不正确,检测到真实格式: ${actualFormat} | MIME: ${file.type} | 文件名: ${file.name}`); if (actualFormat === 'webp' || actualFormat === 'animated-webp' || actualFormat === 'heic') { beginSubmitImageProcessing(form); try { const convertedFile = await runImageProcessingWithTimeout('图片格式转换', async (token) => { let outFile; if (actualFormat === 'heic') { if (!token.cancelled) toast('服务器不接受 HEIC,正在转换为 PNG……', 3000); outFile = await convertHeicToPng(file); if (token.cancelled) return null; console.log(`[interceptReplyForm] HEIC→PNG 转换完成: ${(file.size / 1024).toFixed(1)}KB -> ${(outFile.size / 1024).toFixed(1)}KB`); } else { if (!token.cancelled) toast('服务器不接受 WebP,正在将Webp转换为PNG格式……', 3000); outFile = await convertWebpToAcceptedFormat(file, actualFormat === 'animated-webp'); if (token.cancelled) return null; console.log(`[interceptReplyForm] WebP→PNG 转换完成: ${(file.size / 1024).toFixed(1)}KB -> ${(outFile.size / 1024).toFixed(1)}KB`); } // 转换后如果大小超限,接入压缩流程 if (outFile.size > SUBMIT_IMAGE_LIMIT_BYTES) { if (!token.cancelled) { console.log(`[interceptReplyForm] 转换后 ${(outFile.size / 1024).toFixed(1)}KB 仍超限,接入压缩流程`); toast(`转换后 ${(outFile.size / 1024).toFixed(1)}KB 仍超限,正在压缩……`, 3000); } outFile = await compressImageForRetry(outFile, 'png', (message, duration, options) => { if (!token.cancelled) toast(message, duration, options); }); if (token.cancelled) return null; console.log(`[interceptReplyForm] 压缩完成: ${(outFile.size / 1024).toFixed(1)}KB`); } return outFile; }); if (!convertedFile) return; resetIllegalRetryState({ clearOriginalContent: false }); rememberPreparedSubmitImage(file, convertedFile); const newFD = cloneFormDataWithImage(fd, convertedFile); toast(`格式已转换为 PNG,大小 ${(convertedFile.size / 1024).toFixed(1)}KB,正在重新提交……`, 2000); await doSubmit(newFD, true); return; } catch (convertErr) { console.error('[interceptReplyForm] 图片格式转换失败:', convertErr); toast(convertErr && convertErr.message ? convertErr.message : '图片格式转换失败,请手动转换为 JPG/PNG 后再试', 0, { type: 'error' }); return; } finally { finishSubmitImageProcessing(form); } } else { // 非 WebP 格式被服务器拒绝,无法自动转换 toast('服务器拒绝此图片格式,请手动转换为 JPG/PNG 后重试', 4000); return; } } } // 检查是否是图片大小错误 if (errorKind === 'image-size') { if (!cfg.interceptReplyFormAutoCompress) { try { toast(msg, 0, { type: 'error', key: 'send-error' }); } catch (e) { console.warn('[interceptReplyForm] toast error for image-size message:', msg, e); } return; } const fileInput = form.querySelector('input[type="file"][name="image"]'); const file = fileInput && fileInput.files && fileInput.files[0]; if (file && !isRetry && !prepared) { beginSubmitImageProcessing(form); try { const actualFormat = await detectImageFormat(file); console.log(`[interceptReplyForm] 检测到真实格式: ${actualFormat} | MIME: ${file.type} | 文件名: ${file.name}`); toast(`图片大小 ${(file.size / 1024).toFixed(1)}KB(>2048KB),正在尝试自动压缩`, 3000); console.log(`[interceptReplyForm] 图片大小: ${(file.size / 1024).toFixed(1)}KB,开始压缩……`); const compressedFile = await runImageProcessingWithTimeout('图片压缩', (token) => compressImageForRetry(file, actualFormat, (message, duration, options) => { if (!token.cancelled) toast(message, duration, options); }) ); resetIllegalRetryState({ clearOriginalContent: false }); rememberPreparedSubmitImage(file, compressedFile); const newFD = cloneFormDataWithImage(fd, compressedFile); const gifCompressionDurationMs = Number(compressedFile && compressedFile.__xdexGifCompressionDurationMs); const gifCompressionSeconds = Number.isFinite(gifCompressionDurationMs) && gifCompressionDurationMs >= 0 ? gifCompressionDurationMs / 1000 : null; toast(`图片已压缩至 ${(compressedFile.size / 1024).toFixed(1)}KB${gifCompressionSeconds == null ? '' : `,耗时 ${gifCompressionSeconds.toFixed(2)} 秒`},正在重新提交……`, 2000); await doSubmit(newFD, true); return; } catch (compressErr) { console.error('[interceptReplyForm] 图片压缩失败:', compressErr); toast(compressErr && compressErr.message ? compressErr.message : '图片压缩失败,请手动压缩后再试', 0, { type: 'error' }); return; } finally { finishSubmitImageProcessing(form); } } else if (isRetry || prepared) { toast('压缩后图片仍然超过限制,请手动压缩后再试', 3000); return; } } // 非法图像文件 + GIF:可能是无 GCT 等结构问题,尝试用 gifsicle 重新编码后重试 if (errorKind === 'illegal-image' && !isRetry) { const fileInput = form.querySelector('input[type="file"][name="image"]'); const file = fileInput && fileInput.files && fileInput.files[0]; if (file && /\.gif$/i.test(file.name)) { beginSubmitImageProcessing(form); try { console.log('[interceptReplyForm] 检测到非法GIF,尝试用gifsicle重新编码……'); const reencodedFile = await runImageProcessingWithTimeout( 'GIF 修复', () => reencodeGifWithGifsicle(file), '请更换图片,或手动修复/压缩后重试。' ); rememberPreparedSubmitImage(file, reencodedFile); const newFD = cloneFormDataWithImage(fd, reencodedFile); toast('GIF结构已修复,正在重新提交……', 2000); await doSubmit(newFD, true); return; } catch (reencodeErr) { console.error('[interceptReplyForm] GIF重编码失败:', reencodeErr); toast(reencodeErr && reencodeErr.message ? reencodeErr.message : 'GIF重编码失败,请手动处理', 0, { type: 'error' }); return; } finally { finishSubmitImageProcessing(form); } } } // 如果不是"含有非法词语"的特殊情况,直接提示并返回,避免被后续分支忽略 if (errorKind !== 'illegal-word') { try { toast(msg, 0, { type: 'error', key: 'send-error' }); } catch (e) { console.warn('[interceptReplyForm] toast error for message:', msg, e); } return; } if (errorKind === 'illegal-word') { if (cfg.interceptReplyFormUnvcode) { // 新增:优先判断 interceptReplyFormU200B if (cfg.interceptReplyFormU200B) { // 新增:初始化或递增重试计数 form.__illegalRetryCountU200B = (form.__illegalRetryCountU200B || 0); // 新增:检查是否已经重试过 if (form.__illegalRetryCountU200B >= 1) { toast('插入零宽空格后仍提交失败,非法词语可能存在于url中,请手动处理', 0, { type: 'error', key: 'send-error' }); restoreOriginalSubmitContent(form); form.__originalContent = null; form.__illegalRetryCountU200B = 0; return; } const currentInput = getCurrentSubmitContent(form, formData); // 构造安全文本:对所有非 URL 段的中文与英文字符插入 U+200B const safeText = processTextPreservingHiddenTags(currentInput, (segment) => insertZwspAfterHanAndAsciiLettersOutsideUrls(segment)); const newFD = createContentRetryFormData(form, safeText); // 新增:递增计数 form.__illegalRetryCountU200B++; toast('已尝试插入零宽空格模式并重试提交', 2000); await doSubmit(newFD, false); return; } else { // 原有三次重试逻辑 const normalRetries = 2; // 前两次正常替换 const fallbackRetryIndex = 2; // 第三次(索引为2)执行保底 const maxRetriesAll = 3; // 共尝试三次:0、1(正常),2(保底) form.__illegalRetryCount = (form.__illegalRetryCount || 0); const currentInput = getCurrentSubmitContent(form, formData); if (form.__illegalRetryCount === 0) { form.__originalContent = currentInput; } if (form.__illegalRetryCount >= maxRetriesAll) { toast('unvcode替换后仍提交失败,已恢复原始文本,请手动处理', 0, { type: 'error', key: 'send-error' }); restoreOriginalSubmitContent(form); resetCacheForFailedContent(form.__originalContent); form.__originalContent = null; form.__illegalRetryCount = 0; return; } let safeText; if (form.__illegalRetryCount < normalRetries) { safeText = processTextPreservingHiddenTags(currentInput, (segment) => unvcodeSelective(segment)); } else { safeText = processTextPreservingHiddenTags(currentInput, (segment) => fallbackInsertZWSP(segment)); } const shouldRetry = (form.__illegalRetryCount < normalRetries && safeText !== currentInput) || (form.__illegalRetryCount === fallbackRetryIndex); if (shouldRetry) { toast('已尝试unvcode替换模式并重试提交', 2000); const newFD = createContentRetryFormData(form, safeText); form.__illegalRetryCount++; await doSubmit(newFD, false); return; } toast(msg, 0, { type: 'error', key: 'send-error' }); } } else { toast(msg, 0, { type: 'error', key: 'send-error' }); } } } }) .catch((err) => { console.error('[interceptReplyForm] fetch error:', err); clearSubmitPreflightStatus(); if (err && err.name === 'AbortError') { form.__submitUnknown = true; toast('提交超时:结果未知,请刷新页面确认是否已发出,避免重复提交。', 0, { type: 'error', key: 'send-error' }); return; } if (isSubmitNetworkError(err)) { form.__submitUnknown = true; toast(SUBMIT_NETWORK_UNKNOWN_MSG, 0, { type: 'error', key: 'send-error' }); return; } toast(formatSubmitError(err), 0, { type: 'error', key: 'send-error' }); }) .finally(() => { clearTimeout(abortTimer); if (form.__submitAbortTimer === abortTimer) form.__submitAbortTimer = 0; if (form.__submitController === controller) form.__submitController = null; }); } // 防重复提交:__submitting 从点击发送保持到请求有确定结果(成功/失败/超时)。 // 15s 看门狗只提示「结果未知」,不再解锁 —— 请求可能仍在飞行,解锁会让用户重复发送。 // 真正的超时由 doSubmit 的 AbortController 负责(按上传体积放宽)。 const SUBMIT_LOCK_TIMEOUT_MS = 30000; const SUBMIT_RESULT_UNKNOWN_MSG = '提交结果未知:请求超过 30 秒未返回。请刷新页面确认是否已发出,避免重复提交。'; const SUBMIT_NETWORK_UNKNOWN_MSG = '网络异常,结果未知:请刷新页面确认是否已发出,避免重复提交。'; const SUBMIT_IMAGE_PROCESSING_TIMEOUT_MS = 180000; const SUBMIT_IMAGE_PROCESSING_TIMEOUT_MSG = '发送前处理超时(180 秒),本次未发送。请手动压缩/转换图片后重试。'; const SUBMIT_REQUEST_TIMEOUT_BASE_MS = 30000; const SUBMIT_REQUEST_TIMEOUT_PER_MB_MS = 15000; const SUBMIT_REQUEST_TIMEOUT_MAX_MS = 120000; function computeSubmitRequestTimeoutMs(fd) { let bytes = 0; try { const image = fd && typeof fd.get === 'function' ? fd.get('image') : null; if (image && typeof image.size === 'number' && isFinite(image.size)) bytes = image.size; } catch (e) { /* 取不到大小就按纯文本超时处理 */ } const mb = bytes > 0 ? Math.ceil(bytes / (1024 * 1024)) : 0; return Math.min(SUBMIT_REQUEST_TIMEOUT_MAX_MS, SUBMIT_REQUEST_TIMEOUT_BASE_MS + mb * SUBMIT_REQUEST_TIMEOUT_PER_MB_MS); } const clearSubmitLockTimer = (f) => { if (f && f.__submitLockTimer) { clearTimeout(f.__submitLockTimer); f.__submitLockTimer = 0; } }; const clearSubmitAbortTimer = (f) => { if (f && f.__submitAbortTimer) { clearTimeout(f.__submitAbortTimer); f.__submitAbortTimer = 0; } }; const unlockSubmit = (f) => { clearSubmitLockTimer(f); if (f) { clearSubmitAbortTimer(f); f.__submitController = null; f.__submitImageProcessing = false; f.__submitting = false; } }; function refreshSubmitLockTimer(f) { if (!f || !f.__submitting || f.__submitImageProcessing) return; clearSubmitLockTimer(f); // 请求 15s 仍无结果:只标记「结果未知」并提示刷新确认, // 保持提交锁直到请求真正结束或被 AbortController 中止,避免重复提交 f.__submitLockTimer = setTimeout(() => { f.__submitLockTimer = 0; if (!f.__submitting) return; if (f.__submitImageProcessing) return; f.__submitUnknown = true; toast(SUBMIT_RESULT_UNKNOWN_MSG, 0, { type: 'error', key: 'send-unknown' }); }, SUBMIT_LOCK_TIMEOUT_MS); } function beginSubmitImageProcessing(f) { if (!f) return; f.__submitting = true; f.__submitImageProcessing = true; clearSubmitLockTimer(f); } function finishSubmitImageProcessing(f) { if (f) f.__submitImageProcessing = false; } const lockSubmit = (f) => { if (!f) return; f.__submitting = true; f.__submitImageProcessing = false; // 保留:这里不启动看门狗 —— 饼干确认弹窗的停留时间不应计入网络超时; // 看门狗与真实超时都由 doSubmit 在真正发请求时启动。 }; if (form.__submitting) { toast('发送中,请勿重复提交……'); return; } if (form.__submitUnknown) { // 上一次请求结果未知(可能已发出):先让用户刷新确认,确认后才允许重发。 // 未知标记保留到新请求真正开始(doSubmit)再由本次结果覆盖, // 避免用户确认后又在饼干弹窗点取消、旧结果仍未知却被清掉。 const resend = window.confirm('上次提交结果未知(可能已经发出)。\n请先刷新页面确认是否已发出。\n若确定要再次发送,点「确定」。'); if (!resend) { toast('已取消发送:请先刷新确认上次提交结果', 2500); return; } } lockSubmit(form); // ── 发送前饼干确认(含串内偏好检查) ── const _cookieConfirmCfg = typeof getFilterConfig === "function" ? getFilterConfig() : {}; const _cookieConfirmEnabled = _cookieConfirmCfg.enableCookieSwitch && _cookieConfirmCfg.enableCookieConfirm; if (_cookieConfirmEnabled) { const _resto = (formData.get("resto") || "").toString().trim(); const _tid = PageType.getThreadId(false); const _threadId = _resto || (_tid ? _tid.slice(0, 8) : ""); if (_threadId && _threadId !== '20011114' && /^\d{6,8}$/.test(_threadId)) { const _pref = getThreadCookiePref(_threadId); const _doSend = () => { resetIllegalRetryState({ clearOriginalContent: false }); const _ta = form.querySelector("textarea[name='content']"); form.__originalContent = _ta ? _ta.value : (formData.get("content") || "").toString(); toast('正在发送……', 1500, { queue: false, key: 'send-status' }); doSubmit(formData, false).finally(() => { unlockSubmit(form); }); }; if (_pref) { const _cookieList = getCookiesList(); if (!findCookieByHash(_cookieList, _pref.hash)) { // 默认饼干已失效 toast('本串默认饼干已失效,请重新选择', 0, { type: 'error' }); showCookieConfirmDialog(_threadId, (selectedHash) => { setThreadCookiePref(_threadId, selectedHash); if (typeof refreshCookiePrefSwitchState === 'function') refreshCookiePrefSwitchState(); _doSend(); }, () => { unlockSubmit(form); }, { mode: 'setDefault' }); return; } const _curCookie = getCurrentCookie(); const _curHash = _curCookie ? abbreviateName(_curCookie.name) : ''; if (_curHash === _pref.hash) { // 匹配 → 直接发送 _doSend(); return; } // 不匹配 → 弹窗,预选偏好饼干 showCookieConfirmDialog(_threadId, (selectedHash, pinnedHash) => { if (pinnedHash) setThreadCookiePref(_threadId, pinnedHash); if (typeof refreshCookiePrefSwitchState === 'function') refreshCookiePrefSwitchState(); _doSend(); }, () => { unlockSubmit(form); }, { preselectHash: _pref.hash }); return; } // 无偏好 → 直接发送 _doSend(); return; } } // 每次用户触发的新提交,重置重试计数并记录当前原始内容 resetIllegalRetryState({ clearOriginalContent: false }); const textarea = form.querySelector('textarea[name="content"]'); form.__originalContent = textarea ? textarea.value : (formData.get('content') || '').toString(); toast('正在发送……', 1500, { queue: false, key: 'send-status' }); doSubmit(formData, false).finally(() => { unlockSubmit(form); }); }, true); function safeGetConfig() { try { if (typeof SettingPanel !== 'undefined' && typeof GM_getValue === 'function') { const defaults = SettingPanel.defaults || {}; const saved = GM_getValue(SettingPanel.key, {}) || {}; return Object.assign({}, defaults, saved); } } catch (e) {} return null; } } // ————— reply refresh helpers ————— // Phase2 小步:以下两函数与闭包外共用工具同名同语义,注释掉以免遮盖外层实现 // function getRealThreadsList(root = document) { // const lists = Array.from(root.querySelectorAll('.h-threads-list')); // return lists.find(el => !el.closest('.h-preview-box')) || null; // } function getCurrentPage() { // 对齐 originInfo / getImageViewerStartPage:?page= 优先,再 fallback path /page/N return PageType.getPageNum(); } function getMaxPageFromPagination() { const paginations = Array.from(document.querySelectorAll('.uk-pagination.uk-pagination-left.h-pagination')); if (!paginations.length) return null; const last = paginations[paginations.length - 1]; let max = 1; // query + path 分页链接都走 parsePaginationPageNum last.querySelectorAll('a[href], span[href]').forEach(a => { const n = parsePaginationPageNum(a.getAttribute('href') || a.href || ''); if (n) max = Math.max(max, n); }); last.querySelectorAll('li, span').forEach(el => { const nums = (el.textContent || '').match(/\d+/g); if (nums) nums.forEach(n => max = Math.max(max, parseInt(n, 10))); }); return max || null; } function minimalHideEmptyTitleAndEmail(root) { if (!root || !root.querySelectorAll) return; Array.from(root.querySelectorAll('.h-threads-info-title')).forEach(el => { const txt = (el.textContent || '').trim(); if (!txt || txt === '无标题' || /^无标题/i.test(txt)) { el.style.display = 'none'; } }); Array.from(root.querySelectorAll('.h-threads-info-email')).forEach(el => { const txt = (el.textContent || '').trim(); if (!txt || txt === '无名氏' || /^无名氏/i.test(txt)) { el.style.display = 'none'; } }); } // 将拦截中间页的局部刷新保持为增量模式;wasLastPage 涨页检测仍在本包装 function refreshRepliesWithSeamlessPaging(done, dependencies) { const getConfig = dependencies && dependencies.getConfig; const currentPage = getCurrentPage(); const maxPage = getMaxPageFromPagination(); const maxCloned = getMaxClonedPageInDOM(); const resolved = resolveThreadRefreshTargetPage(maxPage, maxCloned, currentPage); try { console.log('[refreshRepliesWithSeamlessPaging] resolve', { href: location.href, currentPage, maxPage, maxCloned, kind: resolved && resolved.kind, targetPage: resolved && resolved.targetPage }); } catch (e) {} // 旧:非末页直接 done 返回 if (resolved.kind === 'skip') { try { console.log('[refreshRepliesWithSeamlessPaging] skip 空跑:非末页且无 cloned 目标', { currentPage, maxPage, maxCloned }); } catch (e) {} if (typeof done === 'function') done(); return; } const targetPage = resolved.targetPage; const list = getRealThreadsList(document); if (!list) { toast('未找到真实列表,无法刷新回复区'); if (typeof done === 'function') done(); return; } const pageAttr = (targetPage && targetPage > 0) ? targetPage : 0; const ensured = ensureThreadRepliesContainer(list, pageAttr); const targetReplies = ensured.targetReplies; if (!targetReplies) { toast('未找到目标回复区'); if (typeof done === 'function') done(); return; } if (ensured.created) { console.log('[refreshRepliesWithSeamlessPaging] 已创建空的回复区容器'); } let fetchUrl; if (targetPage) { // cloned 目标页用 buildThreadPageUrl,避免 /t/.../page/N?page=M 混拼 const tidMatch = PageType.getThreadId(); const tidEl = document.querySelector('[data-threads-id]'); const tid = (tidEl && tidEl.getAttribute('data-threads-id')) || tidMatch || null; if (tid && typeof buildThreadPageUrl === 'function') { fetchUrl = buildThreadPageUrl(tid, targetPage); } else { const url = new URL(location.href, location.origin); url.searchParams.set('page', String(targetPage)); fetchUrl = url.toString(); } } else { fetchUrl = location.href; } try { console.log('[refreshRepliesWithSeamlessPaging] fetchUrl', fetchUrl); } catch (e) {} // 网络层失败重试:最多 2 次退避(300ms/800ms),连续失败才走外层 catch const fetchTextWithRetry = (url, attempt = 0) => { return fetch(url, { credentials: 'include' }) .then((res) => res.text()) .catch((err) => { console.error('[refreshRepliesWithSeamlessPaging] fetch failed', { url, attempt, name: err && err.name, message: err && err.message, at: Date.now() }); if (attempt < 2) { return new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 300 : 800)) .then(() => fetchTextWithRetry(url, attempt + 1)); } throw err; }); }; fetchTextWithRetry(fetchUrl) .then(html => { const doc = new DOMParser().parseFromString(html, 'text/html'); const newList = getRealThreadsList(doc); if (!newList) { toast('未找到最新列表'); if (typeof done === 'function') done(); return; } const newReplies = newList.querySelector('.h-threads-item-replies'); if (!newReplies) { toast('未找到最新回复区'); if (typeof done === 'function') done(); return; } const cfg2 = (typeof getConfig === 'function') ? getConfig() : null; // ★ 计时点:串内页「发送后刷新」的同步 DOM 处理(fragment 预增强 → merge → 逐节点后处理)。 // 本路径与刷新按钮的 refreshRepliesAndCheckNext 是两条独立实现,需分别计量。 // 网络往返已排除在计时点之外。 const __perfToken = startupPerfDebug.begin(); const fragment = document.createElement('div'); fragment.innerHTML = newReplies.innerHTML; stripSystemTipReplies(fragment); try { if (typeof preprocessPageEnhancementsBeforeInsert === 'function') { preprocessPageEnhancementsBeforeInsert(fragment, cfg2); } else { if (typeof hideEmptyTitleAndEmail === 'function') hideEmptyTitleAndEmail($(fragment)); if (cfg2 && typeof applyFilters === 'function') applyFilters(cfg2, fragment); } } catch (e) { console.warn('预处理过滤失败', e); } const merge = appendMissingRepliesByThreadsId(targetReplies, fragment, { replyOnly: true, excludeSystemOnOld: true, excludeSystemOnNew: true }); const appendedNodes = merge.appendedNodes; // ★ 借道回填(串内页):从本次 fetch 的页面提取 fid/页码,把 pending 发言历史 // 记录的字段立即补齐(零新增请求)。fid 取表单 hidden input[name=fid]。 try { const inlineFid = normalizePostHistoryFid(doc.querySelector('form input[name="fid"]') ? doc.querySelector('form input[name="fid"]').value : ''); // 发送时所在页:用户在 /t/xxx/page/N 发送,新回复就在该页(fetch 的就是当前页) const inlinePage = (parseThreadHistoryUrl(location.href) || {}).page || 0; if (inlineFid && appendedNodes.length) { appendedNodes.forEach((node) => { const rid = String(node.getAttribute('data-threads-id') || '').trim(); if (!rid || rid === '9999999') return; const store = getPostHistoryStore(); const now = Date.now(); Object.keys(store.items).forEach((localId) => { const item = store.items[localId]; if (!item || item.status !== 'pending' || item.type !== 'reply') return; if (String(item.id || '') !== rid) return; if (now - (Number(item.submittedAt) || 0) > 180000) return; const titleEl = node.querySelector('.h-threads-info-title'); const emailEl = node.querySelector('.h-threads-info-email'); // 站点渲染的「无标题/无名氏」是占位符不是真实值,不入队 const rawTitle = titleEl ? String(titleEl.textContent || '').trim() : ''; const rawEmail = emailEl ? String(emailEl.textContent || '').trim() : ''; postHistoryFieldPatchQueue.set(localId, { fid: inlineFid, forumName: getPostHistoryForumNameByFid(inlineFid) || '', page: Math.max(1, Number(inlinePage) || Number(item.page) || 0), title: rawTitle && rawTitle !== '无标题' ? rawTitle : '', email: rawEmail && rawEmail !== '无名氏' ? rawEmail : '' }); schedulePostHistoryFieldPatchFlush(); }); }); } } catch (e) {} try { if (typeof hideEmptyTitleAndEmail === 'function') { appendedNodes.forEach(n => { try { hideEmptyTitleAndEmail($(n)); } catch (_) {} }); } if (cfg2 && typeof applyFilters === 'function') { appendedNodes.forEach(n => { try { applyFilters(cfg2, n); } catch (_) {} }); } if (typeof enablePostExpand === 'function') { appendedNodes.forEach(n => { try { enablePostExpand(n); } catch (_) {} }); } } catch (e) {} // ★ 计时收尾:只覆盖响应到达后的同步 DOM 工作,不含下面的 50ms 异步增强段 // (异步段已由 applyPageEnhancements.total 单独记录) startupPerfDebug.finish('refresh.postReply.syncDom', __perfToken, () => ({ replies: targetReplies.querySelectorAll ? targetReplies.querySelectorAll('.h-threads-item-reply').length : -1, appended: (appendedNodes && appendedNodes.length) || 0, domPages: document.querySelectorAll('.h-threads-item-replies[data-cloned-page]').length })); setTimeout(() => { try { if (typeof applyPageEnhancements === 'function') { applyPageEnhancements(targetReplies, cfg2 || (typeof getConfig === 'function' ? getConfig() : null), appendedNodes); } else { try { if (typeof hideEmptyTitleAndEmail === 'function') hideEmptyTitleAndEmail($(targetReplies)); } catch (e) {} try { if (typeof highlightPO === 'function') highlightPO(targetReplies); } catch (e) {} try { if (typeof enableHDImageAndLayoutFix === 'function') enableHDImageAndLayoutFix(document); } catch (e) {} try { if (typeof enableHDImage === 'function') enableHDImage(document); } catch (e) {} try { if (typeof initContent === 'function') initContent(targetReplies); } catch (e) {} } } catch (e) {} }, 50); // wasLastPage 需在写回前读取 oldPag const oldPagsBefore = document.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); const oldPagBefore = oldPagsBefore.length ? oldPagsBefore[oldPagsBefore.length - 1] : null; let wasLastPage = false; if (oldPagBefore) { const oldNextLi = Array.from(oldPagBefore.querySelectorAll('li')).find(li => /下一页|下页|Next|›|»|→/i.test(li.textContent.trim()) ); wasLastPage = !!(oldNextLi && oldNextLi.classList.contains('uk-disabled') && !oldNextLi.querySelector('a')); } const newPags = doc.querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'); const newPag = newPags.length ? newPags[newPags.length - 1] : null; prepareAndSyncBottomPagination(doc); if (wasLastPage && newPag) { const newNextLi = Array.from(newPag.querySelectorAll('li')).find(li => /下一页|下页|Next|›|»|→/i.test(li.textContent.trim()) ); const hasNewPage = !!(newNextLi && !newNextLi.classList.contains('uk-disabled') && !!newNextLi.querySelector('a')); if (hasNewPage) { const nextLink = newNextLi.querySelector('a'); const nextPageNum = nextLink ? parsePaginationPageNum(nextLink.getAttribute('href') || nextLink.href || '') : null; if (nextPageNum) { // 发送成功后的单次刷新无代际并发,不引用其他函数的 activeGeneration(跨作用域 ReferenceError) showRefreshStatus('发现' + nextPageNum + '页,正在加载……', 700); if (window.SeamlessPaging && typeof window.SeamlessPaging.loadNext === 'function') { setTimeout(() => { window.SeamlessPaging.loadNext(); }, 100); } } } } // 过滤兜底改为 scoped:只重算本次新增的回复区。全文档调用会对整页所有回复 // 重跑过滤/PO 注释列重算(串越长越慢),且 50ms 后 applyPageEnhancements(targetReplies) // 内部还会 scoped 调一次,全文档版是冗余的(发送后既有回复的过滤状态不变,无需重算)。 try { if (cfg2 && typeof applyFilters === 'function') { try { refreshFilterDisplay(cfg2, targetReplies); } catch (e) {} } } catch (e) {} if (typeof done === 'function') done(); }) .catch((err) => { console.error('[refreshRepliesWithSeamlessPaging] refresh failed', { url: fetchUrl, name: err && err.name, message: err && err.message, at: Date.now() }); toast('刷新回复区失败,可能已有新回复,请手动刷新'); if (typeof done === 'function') done({ ok: false, reason: 'fetch-failed' }); }); } function isCookieDropdownShortcut(e) { return !!(e && e.ctrlKey && (e.code === 'Backslash' || e.code === 'IntlBackslash' || e.key === '\\')); } function logCookieDropdownShortcutStage(stage, detail) { try { console.log('[XDEX cookie shortcut]', stage, Object.assign({ runtime: XDEX_RUNTIME && XDEX_RUNTIME.kind }, detail || {})); } catch (e) {} } function openCookieDropdownFromShortcut(focusBackTarget) { const dropdown = document.getElementById('cookie-dropdown'); if (!dropdown) { logCookieDropdownShortcutStage('dropdown-missing', { activeTag: document.activeElement && document.activeElement.tagName, textareas: document.querySelectorAll('form textarea[name="content"]').length, switcher: !!document.getElementById('cookie-switcher-ui') }); return false; } dropdown.__focusBackTarget = focusBackTarget || document.activeElement || null; dropdown.__openedValue = dropdown.value; dropdown.focus(); logCookieDropdownShortcutStage('dropdown-focus', { options: dropdown.options ? dropdown.options.length : 0, value: dropdown.value, hasShowPicker: typeof dropdown.showPicker === 'function' }); if (XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension') { return openCookieShortcutMenu(dropdown, focusBackTarget); } if (typeof dropdown.showPicker === 'function') { try { dropdown.showPicker(); logCookieDropdownShortcutStage('showPicker-called'); return true; } catch (e) { logCookieDropdownShortcutStage('showPicker-failed', { message: e && e.message ? e.message : String(e) }); } } const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }); dropdown.dispatchEvent(mouseDownEvent); logCookieDropdownShortcutStage('fallback-mousedown-dispatched'); return true; } function installCookieDropdownShortcutHandler() { if (installCookieDropdownShortcutHandler.__installed) return; installCookieDropdownShortcutHandler.__installed = true; logCookieDropdownShortcutStage('handler-installed'); function onCookieDropdownShortcutKeydown(e) { if (e && e.ctrlKey) { logCookieDropdownShortcutStage('ctrl-keydown', { key: e.key, code: e.code, targetTag: e.target && e.target.tagName }); } if (!isCookieDropdownShortcut(e)) return; logCookieDropdownShortcutStage('shortcut-matched', { key: e.key, code: e.code }); if (!openCookieDropdownFromShortcut(document.activeElement)) return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') { e.stopImmediatePropagation(); } } document.addEventListener('keydown', onCookieDropdownShortcutKeydown, true); } // 绑定 Ctrl+Enter 快捷提交表单 + 全局打开浮窗 function bindCtrlEnter(ta) { if (!ta || ta.__ctrlEnterBound) return; const form = ta.closest('form'); if (!form) return; ta.__ctrlEnterBound = true; // 焦点在 textarea 内时:Ctrl+Enter 提交,Ctrl+\ 打开 cookie 下拉框 ta.addEventListener('keydown', function (e) { // Ctrl+Enter 提交表单 if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') { e.stopImmediatePropagation(); } try { form.requestSubmit(); } catch (_) { form.submit(); } } // Ctrl+\ 打开 cookie 下拉框 if (isCookieDropdownShortcut(e)) { if (openCookieDropdownFromShortcut(ta)) { e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') { e.stopImmediatePropagation(); } } } // Ctrl+/ 打开颜文字选择框 if (e.ctrlKey && e.key === '/') { e.preventDefault(); e.stopPropagation(); const kaomojiTrigger = document.querySelector('.kaomoji-trigger'); if (kaomojiTrigger) { kaomojiTrigger.click(); } } }); // 全局监听:Ctrl+Enter 打开浮窗 document.addEventListener('keydown', function (e) { // Ctrl + Enter 打开浮窗(已有逻辑) if (e.ctrlKey && e.key === 'Enter') { const active = document.activeElement; const isTextarea = active && active.tagName === 'TEXTAREA'; if (!isTextarea) { e.preventDefault(); e.stopPropagation(); const replyBtn = document.querySelector('.hld__docker [data-type="REPLY"]'); if (replyBtn) replyBtn.click(); } } }, false); } /* -------------------------------------------------- * tag 12. 高亮Po主+回复表单UI调整 * -------------------------------------------------- */ // 高亮 Po 主(内置并先执行楼层编号) function highlightPO(root) { const scope = root || document; return startupPerfDebug.measure('highlightPO', () => { const poTextColor = '#00FFCC'; // Po 本体颜色 const iconWidthEm = 3.0; // 所有图标统一宽度 // 子函数:先为当前页面所有回复区编号(原 updateReplyNumbers 逻辑) function updateReplyNumbersLocal() { const repliesList = scope.querySelectorAll ? Array.from(scope.querySelectorAll('.h-threads-item-replies')).concat(scope.matches && scope.matches('.h-threads-item-replies') ? [scope] : []) : Array.from(document.querySelectorAll('.h-threads-item-replies')); repliesList.forEach(replies => { let effectiveCount = 0; replies.querySelectorAll('.h-threads-item-reply-icon').forEach(icon => { const reply = icon.closest('[data-threads-id]'); if (!reply) return; if (reply.getAttribute('data-threads-id') === '9999999') { // 特殊:小提示串号 -> 编号 0 icon.textContent = circledNumber(0); } else { // 普通回复 -> 依次递增 effectiveCount++; icon.textContent = `『${effectiveCount}』`; } }); }); } // 统一设置所有回复图标的宽度与基础样式 const icons = scope.querySelectorAll ? scope.querySelectorAll('.h-threads-item-reply-icon') : document.querySelectorAll('.h-threads-item-reply-icon'); icons.forEach(icon => { icon.style.display = 'inline-block'; icon.style.width = iconWidthEm + 'em'; icon.style.textAlign = 'center'; icon.style.position = 'relative'; icon.style.fontWeight = 'normal'; }); // 先编号,再做 Po 标替换 updateReplyNumbersLocal(); // 替换 PO 回复的数字为 Po,并加角标 const replies = scope.querySelectorAll ? scope.querySelectorAll('.h-threads-item-reply') : document.querySelectorAll('.h-threads-item-reply'); replies.forEach(reply => { const main = reply.querySelector('.h-threads-item-reply-main'); const icon = reply.querySelector('.h-threads-item-reply-icon'); if (!main || !icon) return; const isPO = !!main.querySelector('span.uk-text-primary.uk-text-small'); if (!isPO) return; let html = icon.innerHTML; const m = html.match(/『(\d+)』/); if (!m) return; const originalNumber = m[1]; // 原数字 const poHTML = `『<span style="color:${poTextColor}; font-weight:bold">Po</span>』`; // 替换数字为 Po html = html.replace(m[0], poHTML); icon.innerHTML = html; // 角标(避免重复添加) if (!icon.querySelector('.po-n-badge')) { const badge = document.createElement('span'); badge.className = 'po-n-badge'; badge.textContent = originalNumber; Object.assign(badge.style, { position: 'absolute', top: '-0.55em', right: '-0.6em', fontSize: '10px', lineHeight: '1', fontWeight: '600', color: 'initial', background: 'transparent', pointerEvents: 'none', }); icon.appendChild(badge); } }); }, () => startupPerfDebug.summarizeRoot(document)); } // 初次执行 highlightPO(); // 正文服务端等效字数:首尾 U+0020/换行不计;内部换行按 8;其余字符按 1 const POST_FORM_CONTENT_SERVER_MAX_LENGTH = 5000; function countPostContentServerChars(raw) { let s = String(raw || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); s = s.replace(/^[ \n]+/, '').replace(/[ \n]+$/, ''); if (!s) return 0; let n = 0; for (let i = 0; i < s.length; i++) { n += s.charCodeAt(i) === 10 ? 8 : 1; } return n; } // IME(拼音/日文等)组字期间不计算字数: // 组字过程中浏览器会带着「拼音串」连续触发 input(如输入 nihao 时值为 "nihao"), // 此时统计会出现数字乱跳、并可能误判超限标红;等 compositionend(候选词上屏)后再算一次。 // 兼容两种事件顺序(compositionend→input 或 input→compositionend):组字中一律跳过, // 上屏时统一补算,两种顺序下结果都正确。 function bindImeAwareCharCounter(el, update) { if (!el || typeof update !== 'function') return; if (el.__xdexImeCounterGuarded) el.removeEventListener('input', el.__xdexImeCounterGuarded); if (el.__xdexImeCounterStart) el.removeEventListener('compositionstart', el.__xdexImeCounterStart); if (el.__xdexImeCounterEnd) el.removeEventListener('compositionend', el.__xdexImeCounterEnd); el.__xdexImeCounterStart = () => { el.__xdexImeComposing = true; }; el.__xdexImeCounterEnd = () => { el.__xdexImeComposing = false; update(); }; el.__xdexImeCounterGuarded = () => { if (el.__xdexImeComposing) return; update(); }; el.addEventListener('compositionstart', el.__xdexImeCounterStart); el.addEventListener('compositionend', el.__xdexImeCounterEnd); el.addEventListener('input', el.__xdexImeCounterGuarded); el.__xdexImeComposing = false; update(); } //回复表单UI调整 function enhancePostFormLayout(formOpt) { // 浮窗内表单优先;允许 open() 传入当前 form,避免 early 时扫到错误节点 const form = formOpt || document.querySelector('.qp-body form[action*="doReplyThread"], .qp-body form[action*="doPostThread"]') || document.querySelector('form[action*="doReplyThread"], form[action*="doPostThread"]'); if (!form) return; // 定位“标题”行与“颜文字”行 const allRows = Array.from(form.querySelectorAll('.h-post-form-grid')); let titleRow = null, emoticonRow = null; for (const row of allRows) { const titleText = row.querySelector('.h-post-form-title')?.textContent?.trim() || ''; if (titleText === '标题') titleRow = row; if (row.querySelector('.kaomoji-trigger') || row.querySelector('#h-emot-select')) { emoticonRow = row; } } // 1) 先把送出按钮移到“颜文字”行,并让整行用 flex 不换行,按钮推到行最右 // 已搬过则跳过,避免 early open / ready 重复搬 if (titleRow && emoticonRow && !emoticonRow.querySelector('input[type="submit"]')) { const sendBtnCell = titleRow.querySelector('.h-post-form-option'); const sendBtn = sendBtnCell?.querySelector('input[type="submit"]'); if (sendBtn) { // 让“颜文字”整行用 flex 布局,禁止换行,垂直居中 Object.assign(emoticonRow.style, { display: 'flex', flexWrap: 'nowrap', alignItems: 'center', width: '100%' }); // 创建一个右侧容器,使用 margin-left:auto 将其推到最右 const btnWrapper = document.createElement('div'); Object.assign(btnWrapper.style, { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '6px' }); const contentTextarea = form.querySelector('textarea[name="content"].h-post-form-textarea'); if (contentTextarea) { let contentCounter = btnWrapper.querySelector('.xdex-post-content-char-count'); if (!contentCounter) { contentCounter = document.createElement('span'); contentCounter.className = 'xdex-post-content-char-count'; // 「字数:」前缀固定不动,数字放进定宽盒子(4 位,对应上限 5000)左对齐: // · 字数 0 → 42 → 128 → 5000 时,前缀与左侧元素都不移动 // · 若整块右对齐,位数增加时前缀会向左推,出现左右抖动 contentCounter.style.cssText = 'font-size:12px;color:#888;white-space:nowrap;flex:0 0 auto;' + 'display:inline-flex;align-items:baseline;'; contentCounter.appendChild(document.createTextNode('字数:')); const numSpan = document.createElement('span'); numSpan.className = 'xdex-post-content-char-count-num'; // 定宽 4ch(对应上限 5000)+ 内部「右对齐」: // · 数字贴住右侧(靠右贴边),前缀「字数:」与右侧「送出」都不移动 // · 盒子宽度固定 → 0 → 42 → 5000 时布局完全静止 numSpan.style.cssText = 'display:inline-block;min-width:4ch;text-align:right;' + 'font-variant-numeric:tabular-nums;font-feature-settings:\'tnum\' 1;'; contentCounter.appendChild(numSpan); btnWrapper.appendChild(contentCounter); } const updateContentCounter = () => { const len = countPostContentServerChars(contentTextarea.value); const numSpan = contentCounter.querySelector('.xdex-post-content-char-count-num'); // 只更新数字盒(前缀「字数:」不动,宽度由定宽盒锁定) if (numSpan) numSpan.textContent = String(len); else contentCounter.textContent = '字数:' + len; contentCounter.style.color = len > POST_FORM_CONTENT_SERVER_MAX_LENGTH ? '#e53935' : '#888'; }; // 拼音组字期间不计数(见 bindImeAwareCharCounter) bindImeAwareCharCounter(contentTextarea, updateContentCounter); } btnWrapper.appendChild(sendBtn); // 将按钮容器添加到“颜文字”行 emoticonRow.appendChild(btnWrapper); // 表单重建后冷却提示需重新挂到「字数」左侧(切饼干/刷新页面后仍显示剩余时间) try { renderCookieCooldown(); } catch (e) {} } } // 2) 折叠「标题 / 名称(含管理员) / E-mail」为一个折叠面板,并固定顺序 // 重新抓一次,避免移动节点导致 NodeList 顺序问题 const freshRows = Array.from(form.querySelectorAll('.h-post-form-grid')); const norm = (s) => String(s || '').replace(/[\s-]+/g, '').toLowerCase(); const rowMap = new Map(); for (const row of freshRows) { const label = row.querySelector('.h-post-form-title')?.textContent || ''; const k = norm(label); if (k === '标题') rowMap.set('title', row); else if (k === '名称') rowMap.set('name', row); // 原文“名 称”会被规整成“名称” else if (k === 'email') rowMap.set('email', row); // 原文 “E-mail” } const rowsToCollapse = ['title', 'name', 'email'] .map(k => rowMap.get(k)) .filter(Boolean); // 关闭浏览器推荐填充(title/name/email) form.setAttribute('autocomplete', 'off'); form.setAttribute('data-form-type', 'other'); const noPersistInputs = rowsToCollapse.flatMap(row => Array.from(row.querySelectorAll('input[type="text"], textarea')) ); const POST_FORM_OPTIONAL_MAX_LENGTH = 15; const POST_FORM_EMAIL_MAX_LENGTH = 100; noPersistInputs.forEach(input => { input.setAttribute('autocomplete', 'off'); input.setAttribute('autocapitalize', 'off'); input.setAttribute('autocorrect', 'off'); input.setAttribute('spellcheck', 'false'); input.setAttribute('aria-autocomplete', 'none'); input.setAttribute('data-lpignore', 'true'); input.setAttribute('data-form-type', 'other'); // 标题/名称:15 字,超限红字提醒;E-mail:100 字,maxlength 拦住后不再标红 const isEmailInput = (input.getAttribute('name') || '').toLowerCase() === 'email'; const maxLen = isEmailInput ? POST_FORM_EMAIL_MAX_LENGTH : POST_FORM_OPTIONAL_MAX_LENGTH; if (isEmailInput) { input.setAttribute('maxlength', String(POST_FORM_EMAIL_MAX_LENGTH)); } else { // 标题/名称保持可输入并超限提示,不强制 maxlength 卡住 input.removeAttribute('maxlength'); } const inputCell = input.closest('.h-post-form-input'); if (inputCell) { inputCell.style.position = 'relative'; inputCell.style.display = 'flex'; inputCell.style.alignItems = 'center'; inputCell.style.minWidth = '0'; input.style.flex = '1 1 auto'; input.style.minWidth = '0'; // 右侧留空给绝对定位的计数器,避免输入文字被遮挡 input.style.paddingRight = '64px'; } let counter = input.nextElementSibling && input.nextElementSibling.classList?.contains('xdex-post-form-char-count') ? input.nextElementSibling : null; if (!counter) { counter = document.createElement('span'); counter.className = 'xdex-post-form-char-count'; counter.style.cssText = 'font-size:12px;color:#888;white-space:nowrap;position:absolute;right:6px;top:50%;transform:translateY(-50%);pointer-events:none;'; input.insertAdjacentElement('afterend', counter); } const updateCounter = () => { const len = String(input.value || '').length; counter.textContent = len + '/' + maxLen; // E-mail 到 100 已被 maxlength 拦住,保持灰色;标题/名称超 15 才标红 counter.style.color = (!isEmailInput && len > maxLen) ? '#e53935' : '#888'; }; // 拼音组字期间不计数(见 bindImeAwareCharCounter) bindImeAwareCharCounter(input, updateCounter); }); // 已折叠过就不要再包一层(early open 与 ready 都会跑) // 若 toggle 丢失或 Utils.collapse 曾被 .qp-body 守卫挡掉,则补折叠;并清理重复按钮 if (rowsToCollapse.length) { let wrapper = form.querySelector('.collapse-wrapper'); if (!wrapper) { wrapper = document.createElement('div'); wrapper.className = 'collapse-wrapper'; wrapper.style.width = '100%'; rowsToCollapse[0].before(wrapper); rowsToCollapse.forEach(r => wrapper.appendChild(r)); } // 注意:Utils.collapse 展开后文案会变成「点击折叠」,不再含「可选项」 // 若仍只按 /可选项/ 识别,会误判 toggle 丢失 → removeData 后再次 collapse → 多出一个按钮 const isOptionalToggle = (el) => { if (!el || !el.classList) return false; if (!el.classList.contains('xdex-placeholder') || !el.classList.contains('xdex-generic-toggle')) return false; if (el.dataset && el.dataset.xdexOptionalToggle === '1') return true; const t = el.textContent || ''; if (/可选项/.test(t)) return true; // 展开态:文案只剩「点击折叠/点击展开」,靠紧邻 collapse-wrapper 判定 const next = el.nextElementSibling; if (next && next.classList && next.classList.contains('collapse-wrapper') && /点击折叠|点击展开/.test(t)) return true; return false; }; // 合法位置:wrapper 前(Utils.collapse 默认) // 兼容旧逻辑误挪到 form 前(wrap 内)的情况,只保留一个 const nearby = []; if (isOptionalToggle(wrapper.previousElementSibling)) nearby.push(wrapper.previousElementSibling); if (form.previousElementSibling && form.previousElementSibling !== wrapper.previousElementSibling && isOptionalToggle(form.previousElementSibling)) { nearby.push(form.previousElementSibling); } // 浮窗 wrap 内再扫一遍,防止残留重复 const host = form.closest('.qp-content-wrap') || form.parentElement; if (host && host.querySelectorAll) { host.querySelectorAll('.xdex-placeholder.xdex-generic-toggle').forEach((el) => { if (isOptionalToggle(el) && !nearby.includes(el)) nearby.push(el); }); } let keepToggle = nearby.find((el) => el.nextElementSibling === wrapper) || nearby[0] || null; nearby.forEach((el) => { if (el !== keepToggle) el.remove(); }); // 若误挪到 form 外,搬回 wrapper 前,保证位置正确且 hasToggle 判定稳定 if (keepToggle && keepToggle.nextElementSibling !== wrapper) { wrapper.before(keepToggle); } if (typeof Utils !== 'undefined' && typeof Utils.collapse === 'function' && typeof $ === 'function') { const $wrapper = $(wrapper); if (keepToggle) { keepToggle.dataset.xdexOptionalToggle = '1'; keepToggle.style.display = ''; // 已有 toggle 时:只同步标记,不要在用户展开后把内容又藏回去 // Utils.collapse 的 data 在展开后仍会保留;真正展开/收起交给原 click 处理 if (!$wrapper.data('xdex-collapsed') && !$wrapper.is(':visible')) { $wrapper.hide().data('xdex-collapsed', true); wrapper.classList.add('xdex-generic-collapsed'); } else if ($wrapper.data('xdex-collapsed') || $wrapper.is(':hidden')) { wrapper.classList.add('xdex-generic-collapsed'); } } else { // 真的没有 toggle 才补;若 data 还在但按钮丢了,先清再 fold,避免永久卡死 if ($wrapper.data('xdex-collapsed')) { $wrapper.removeData('xdex-collapsed'); wrapper.classList.remove('xdex-generic-collapsed'); $wrapper.show(); } const $ph = Utils.collapse($wrapper, '可选项'); const phEl = ($ph && $ph[0]) || wrapper.previousElementSibling; if (phEl && phEl.classList && phEl.classList.contains('xdex-generic-toggle')) { phEl.dataset.xdexOptionalToggle = '1'; } } } } form.dataset.xdexPostFormLayoutEnhanced = '1'; } /* -------------------------------------------------- * tag 13. 颜文字增强-光标处插入/选择框优化/额外颜文字拓展 * -------------------------------------------------- */ function kaomojiEnhancer() { const SELECTOR = '#h-emot-select'; let enhanceScheduled = false; function runKaomojiEnhancePass() { initInsertAtCaret(); // 功能 1:颜文字插入光标处 extendKaomojiSet(); // 功能 3:颜文字样式拓展,先补稳定 key optimizeSelectorStyle(); // 功能 2:选择框样式优化,基于稳定 key 渲染排序 } function scheduleKaomojiEnhancePass() { if (enhanceScheduled) return; enhanceScheduled = true; setTimeout(() => { enhanceScheduled = false; runKaomojiEnhancePass(); }, 0); } function observeKaomojiSelects() { if (kaomojiEnhancer.__selectObserverInstalled) return; const observeRoot = document.body || document.documentElement; if (!observeRoot) return; kaomojiEnhancer.__selectObserverInstalled = true; const observer = new MutationObserver(mutations => { for (const mutation of mutations) { for (const node of mutation.addedNodes || []) { if (!node || node.nodeType !== 1) continue; if ((node.matches && node.matches(SELECTOR)) || (node.querySelector && node.querySelector(SELECTOR))) { scheduleKaomojiEnhancePass(); return; } } } }); observer.observe(observeRoot, { childList: true, subtree: true }); } // 初始化所有功能 runKaomojiEnhancePass(); observeKaomojiSelects(); /** * 功能 1:选择颜文字后插入到光标位置 */ // BUG:win7与部分win10环境下颜文字插入两次。 function initInsertAtCaret() { const SELECTOR = '#h-emot-select'; const TA_SELECTOR = 'textarea.h-post-form-textarea[name="content"]'; document.querySelectorAll(SELECTOR).forEach(select => { if (select.dataset.kaoBound === '1') return; select.dataset.kaoBound = '1'; const form = select.closest('form'); const textarea = form ? form.querySelector(TA_SELECTOR) : null; if (!textarea) return; let lastStart = 0; let lastEnd = 0; // 记录光标位置 const remember = () => { lastStart = textarea.selectionStart ?? lastStart; lastEnd = textarea.selectionEnd ?? lastEnd; }; ['keyup', 'mouseup', 'select', 'input', 'focus', 'blur'].forEach(ev => textarea.addEventListener(ev, remember, true) ); // 只在 select 上监听 focus 相关事件来记录位置 ['focus', 'mousedown'].forEach(ev => select.addEventListener(ev, remember, true) ); // 防抖保护 let isInserting = false; // 用 change 事件来触发插入 select.addEventListener('change', e => { e.stopImmediatePropagation(); e.preventDefault(); e.stopPropagation(); if (isInserting) return; const val = select.value; if (!val) return; // ★ 在插入前强制记忆一次光标位置 remember(); isInserting = true; insertAtCaret(textarea, val, lastStart, lastEnd); setTimeout(() => { select.value = ''; isInserting = false; }, 50); textarea.focus(); }, true); // 移除 change 事件的监听,避免重复触发 select.addEventListener('change', function(e) { e.stopImmediatePropagation(); e.preventDefault(); e.stopPropagation(); }, true); // 移除原生 jQuery change handler,防止双重插入 try { const jqEvents = $._data(select, 'events'); if (jqEvents && jqEvents.change) { delete jqEvents.change; } } catch (e) {} function insertAtCaret(textarea, text, selStart, selEnd) { // 记录插入前的滚动位置 const prevScrollTop = textarea.scrollTop; // 确定插入位置 let start = Number.isInteger(selStart) ? selStart : textarea.selectionStart; let end = Number.isInteger(selEnd) ? selEnd : textarea.selectionEnd; if (!Number.isInteger(start) || !Number.isInteger(end)) { start = end = textarea.value.length; } // 优先用 execCommand 插入(保留浏览器撤销历史,支持 Ctrl+Z) textarea.focus(); textarea.setSelectionRange(start, end); const success = document.execCommand('insertText', false, text); if (!success) { // 回退:execCommand 不可用时走原逻辑 const before = textarea.value.slice(0, start); const after = textarea.value.slice(end); textarea.value = before + text + after; const newPos = start + text.length; textarea.setSelectionRange(newPos, newPos); textarea.dispatchEvent(new Event('input', { bubbles: true })); } // 恢复滚动条位置 textarea.scrollTop = prevScrollTop; // 更新记忆位置(execCommand 后光标自动在插入文本末尾) lastStart = lastEnd = textarea.selectionStart; } }); } /** * 功能 2:颜文字选择框样式优化 */ function optimizeSelectorStyle() { const GAP = 4; // 单元格间距(缩小) const CHAR_W = 14; // 每个字宽度(px) const CHAR_H = 16; // 每个字高度(px) const PAD = 6; // 浮窗内边距(缩小) const ITEM_W = CHAR_W * 6 + 6; // 大约半个长颜文字宽度 const ITEM_H = CHAR_H * 2 + 4; // 不超过两行字高 const KAO_STATS_KEY = 'kaomojiUsageStats'; let kaomojiStatsReadable = true; function getSortMode() { try { const stateMode = SettingPanel && SettingPanel.state && SettingPanel.state.kaomojiSort; if (stateMode === 'default' || stateMode === 'recent' || stateMode === 'freq') return stateMode; } catch (e) {} try { const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); const mode = cfg.kaomojiSort || 'default'; return (mode === 'default' || mode === 'recent' || mode === 'freq') ? mode : 'default'; } catch (e) { return 'default'; } } function loadKaomojiStats() { try { const v = GM_getValue(KAO_STATS_KEY, {}); kaomojiStatsReadable = !!(v && typeof v === 'object'); return kaomojiStatsReadable ? v : {}; } catch (e) { kaomojiStatsReadable = false; return {}; } } function saveKaomojiStats(stats) { if (!kaomojiStatsReadable) return; try { GM_setValue(KAO_STATS_KEY, stats || {}); } catch (e) {} } function normalizeKaomojiValue(v) { return String(v == null ? '' : v).replace(/\r\n/g, '\n'); } function kaomojiKeyFromOption(opt) { const stableKey = (opt?.dataset?.kaoKey || '').trim(); if (stableKey) return `sk:${stableKey}`; const text = (opt?.textContent || '').trim(); const value = normalizeKaomojiValue(opt?.value); // 用“显示文本 + 实际值”作为唯一键,避免脚本扩展颜文字(尤其富文本)统计丢失或冲突 return `k:${text}\nv:${value}`; } // 同一颜文字可能同时存在多种历史 key: // - sk:extra:xxx / sk:rich:xxx(扩展注入后) // - k:显示文本\nv:实际值(注入前/原生 option) // - 仅显示文本(更旧版本) // 排序/记录时必须合并,否则“最近”会看起来乱跳 function listKaomojiStatAliases(opt) { const label = (opt?.textContent || '').trim(); const value = normalizeKaomojiValue(opt?.value); const aliases = []; const push = (k) => { if (!k || aliases.includes(k)) return; aliases.push(k); }; push(kaomojiKeyFromOption(opt)); if (label) { push(label); // legacy push(`k:${label}\nv:${value}`); // 富文本插入时 value 可能带首尾换行,兼容去换行后的 key const bareVal = value.replace(/^\n+/, '').replace(/\n+$/, ''); if (bareVal !== value) push(`k:${label}\nv:${bareVal}`); // 扩展项稳定 key 的两种常见形态 push(`sk:extra:${label}`); push(`sk:rich:${label}`); } const stableKey = (opt?.dataset?.kaoKey || '').trim(); if (stableKey) push(`sk:${stableKey}`); return aliases; } function readKaomojiStatEntry(stats, key) { const st = stats && key ? stats[key] : null; if (!st || typeof st !== 'object') return { count: 0, lastUsed: 0 }; return { count: Number.isFinite(Number(st.count)) ? Number(st.count) : 0, lastUsed: Number.isFinite(Number(st.lastUsed)) ? Number(st.lastUsed) : 0 }; } function mergeKaomojiStatEntries(entries) { let count = 0; let lastUsed = 0; (entries || []).forEach((st) => { count += Number(st && st.count) || 0; lastUsed = Math.max(lastUsed, Number(st && st.lastUsed) || 0); }); return { count, lastUsed }; } // 把别名统计折叠进主 key,避免排序只读到“空壳 sk key”而丢掉真正的 lastUsed function coalesceKaomojiStatsForOption(stats, opt) { const primary = kaomojiKeyFromOption(opt); const aliases = listKaomojiStatAliases(opt); const merged = mergeKaomojiStatEntries(aliases.map((k) => readKaomojiStatEntry(stats, k))); stats[primary] = merged; aliases.forEach((k) => { if (k !== primary && stats[k]) delete stats[k]; }); return merged; } function getKaomojiStatForOption(stats, opt) { const primary = kaomojiKeyFromOption(opt); // 已折叠过则直接读主 key;若仍有别名残留则按合并值排序 if (stats && stats[primary] && typeof stats[primary] === 'object') { const aliases = listKaomojiStatAliases(opt); const hasAliasResidue = aliases.some((k) => k !== primary && stats[k]); if (!hasAliasResidue) return readKaomojiStatEntry(stats, primary); return mergeKaomojiStatEntries(aliases.map((k) => readKaomojiStatEntry(stats, k))); } return mergeKaomojiStatEntries(listKaomojiStatAliases(opt).map((k) => readKaomojiStatEntry(stats, k))); } function syncKaomojiStatsWithOptions(options) { const stats = loadKaomojiStats(); // 收集当前页面所有颜文字(而非仅当前 select),避免多 select 场景漏合并 const allOpts = []; document.querySelectorAll(SELECTOR).forEach(sel => { Array.from(sel.options || []).forEach(opt => { const label = (opt?.textContent || '').trim(); if (!label || label === '无') return; allOpts.push(opt); }); }); (options || []).forEach(opt => { const label = (opt?.textContent || '').trim(); if (!label || label === '无') return; if (!allOpts.includes(opt)) allOpts.push(opt); }); allOpts.forEach(opt => { const label = (opt?.textContent || '').trim(); if (!label || label === '无') return; coalesceKaomojiStatsForOption(stats, opt); }); // 不主动删除“当前页完全见不到”的孤立 key:扩展颜文字可能晚注入。 // 但同 option 的别名已在 coalesce 中折叠进主 key。 saveKaomojiStats(stats); return stats; } function recordKaomojiUsage(opt) { const label = (opt?.textContent || '').trim(); if (!label || label === '无') return; const key = kaomojiKeyFromOption(opt); const stats = loadKaomojiStats(); // 先合并历史别名,再累加到主 key,避免“记到 A、排到 B” const merged = coalesceKaomojiStatsForOption(stats, opt); stats[key] = { count: (Number(merged.count) || 0) + 1, lastUsed: Date.now() }; saveKaomojiStats(stats); } function getSortedKaomojiOptions(select) { const mode = getSortMode(); const all = Array.from(select.options || []); const base = all .filter(opt => (opt.textContent || '').trim() !== '无') .map((opt, idx) => ({ opt, idx, key: kaomojiKeyFromOption(opt) })); const stats = syncKaomojiStatsWithOptions(base.map(x => x.opt)); if (mode === 'freq') { base.sort((a, b) => { const sa = getKaomojiStatForOption(stats, a.opt); const sb = getKaomojiStatForOption(stats, b.opt); const dc = (Number(sb.count) || 0) - (Number(sa.count) || 0); if (dc !== 0) return dc; // 常用模式:频次相同 -> 最近使用优先 const dt = (Number(sb.lastUsed) || 0) - (Number(sa.lastUsed) || 0); if (dt !== 0) return dt; // 最近时间也相同(如未使用项 1970) -> 默认顺序 return a.idx - b.idx; }); return base.map(x => x.opt); } if (mode === 'recent') { const used = []; const unused = []; base.forEach(x => { const st = getKaomojiStatForOption(stats, x.opt); if ((Number(st.lastUsed) || 0) > 0) used.push(x); else unused.push(x); }); used.sort((a, b) => { const sa = getKaomojiStatForOption(stats, a.opt); const sb = getKaomojiStatForOption(stats, b.opt); const dt = (Number(sb.lastUsed) || 0) - (Number(sa.lastUsed) || 0); if (dt !== 0) return dt; const dc = (Number(sb.count) || 0) - (Number(sa.count) || 0); if (dc !== 0) return dc; return a.idx - b.idx; }); // 未使用保持默认顺序(均视作 1970-01-01 00:00:00) return used.concat(unused).map(x => x.opt); } // default:保持原顺序 return base.map(x => x.opt); } const selects = document.querySelectorAll(SELECTOR); if (!selects.length) return; // 注入样式(只注入一次) if (!document.getElementById('kaomoji-style')) { const style = document.createElement('style'); style.id = 'kaomoji-style'; style.textContent = ` .kaomoji-trigger { display: inline-flex; align-items: center; height: 26px; line-height: 26px; padding: 0 8px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; white-space: nowrap; user-select: none; } .kaomoji-panel { position: fixed; z-index: 2147483647; display: none; grid-template-columns: repeat(auto-fill, minmax(${ITEM_W}px, 1fr)); gap: ${GAP}px; padding: ${PAD}px; border: 1px solid #ccc; border-radius: 6px; background: #fff; box-shadow: 0 6px 16px rgba(0,0,0,0.12); box-sizing: border-box; overflow-y: auto; /* 关键调整 */ width: auto; /* 宽度由 JS 按 qp-quote 动态设置 */ max-width: calc(100vw - ${PAD * 2}px); /* 不超过视口宽度 */ min-width: ${ITEM_W}px; /* 至少一列 */ max-height: calc(${ITEM_H}px * 5 + ${GAP}px * 4 + ${PAD}px * 2); } .kaomoji-item { width: ${ITEM_W}px; height: ${ITEM_H}px; display: flex; align-items: center; justify-content: center; padding: 2px; border-radius: 4px; cursor: pointer; user-select: none; text-align: center; font-size: 14px; line-height: 1.2; word-break: break-word; } /* 禁用默认 focus 白框/发白 */ .kaomoji-item:focus { outline: none; } /* 鼠标悬停:仅变色,不显示蓝框(避免与键盘选中混淆) */ .kaomoji-item:hover { background: #f2f2f2; } /* 键盘选中:变色 + 蓝框(专属样式) */ .kaomoji-item.kaomoji-active { background: #e0e0e0; outline: 2px solid #66ccff; outline-offset: -2px; } `; document.head.appendChild(style); } selects.forEach(select => { if (select.dataset.kaoStyled === '1') return; select.dataset.kaoStyled = '1'; select.style.display = 'none'; const trigger = document.createElement('button'); trigger.type = 'button'; trigger.className = 'kaomoji-trigger'; trigger.textContent = '选择颜文字'; // 颜文字按钮右侧快捷排序下拉(复制设置面板的 #sp_kaomojiSort) const quickSort = document.createElement('select'); quickSort.className = 'sp_kaomojiSort_copy'; quickSort.setAttribute('data-copy-of', 'sp_kaomojiSort'); quickSort.style.height = '26px'; quickSort.style.marginLeft = '6px'; quickSort.style.flex = '0 0 auto'; // 复制设置面板中的下拉项;若面板未渲染则使用同款兜底项 const panelSort = document.querySelector('#sp_kaomojiSort'); if (panelSort && panelSort.options && panelSort.options.length) { Array.from(panelSort.options).forEach(op => { quickSort.appendChild(new Option(op.textContent, op.value)); }); } else { quickSort.appendChild(new Option('默认', 'default')); quickSort.appendChild(new Option('最近', 'recent')); quickSort.appendChild(new Option('常用', 'freq')); } quickSort.value = getSortMode(); const panel = document.createElement('div'); panel.className = 'kaomoji-panel'; //panel.tabIndex = -1; // 👈 添加:使 panel 可以接收焦点 // 复制颜文字:右键菜单与键盘 C 共用;成功/失败反馈一致,焦点回到对应文本框 function copyKaomojiValue(value) { const textarea = (select && select.closest && select.closest('form')) ? select.closest('form').querySelector('textarea.h-post-form-textarea[name="content"]') : null; const restoreFocus = () => { try { if (textarea && typeof textarea.focus === 'function') textarea.focus(); } catch (e) {} }; return writeClipboardText(value, null) .then(() => { toast('颜文字已复制', 900, { queue: false, key: 'kaomoji-copy' }); hidePanel(); restoreFocus(); }) .catch((err) => { console.warn('[kaomoji] copy failed:', err); toast('颜文字复制失败', 900, { queue: false, key: 'kaomoji-copy' }); restoreFocus(); }); } function renderPanelItems() { while (panel.firstChild) panel.removeChild(panel.firstChild); const options = getSortedKaomojiOptions(select); options.forEach(opt => { const item = document.createElement('div'); item.className = 'kaomoji-item'; item.textContent = opt.textContent; item.dataset.value = opt.value; item.tabIndex = -1; // 使 item 可聚焦,避免 focus 时“发白/丢焦点” item.addEventListener('click', () => { recordKaomojiUsage(opt); // 无论是否开启排序都记录 select.value = opt.value; select.dispatchEvent(new Event('change', { bubbles: true })); trigger.textContent = opt.textContent || '选择颜文字'; hidePanel(); }); item.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); copyKaomojiValue(opt.value); }); panel.appendChild(item); }); } renderPanelItems(); select.parentNode.insertBefore(trigger, select.nextSibling); trigger.insertAdjacentElement('afterend', quickSort); // 默认位置(非 qp-quote)下,uk-width-1-5 会导致换行:扩展输入容器并强制同排 const inputCell = select.parentElement; if (inputCell) { Object.assign(inputCell.style, { display: 'flex', alignItems: 'center', flexWrap: 'nowrap', minWidth: '0', gap: '6px' }); trigger.style.flex = '0 0 auto'; if (!trigger.closest('.qp-quote')) { inputCell.classList.remove('uk-width-1-5'); inputCell.style.width = 'auto'; inputCell.style.maxWidth = 'none'; inputCell.style.flex = '1 1 auto'; } } document.body.appendChild(panel); function getQuoteElement() { // 仅当当前触发器本身位于回复面板中时,才跟随 qp-quote 定位 // 避免默认页面场景误命中页面上其他/隐藏的 qp-quote,导致面板贴边 return trigger.closest('.qp-quote'); } function getPanelAnchorRect() { const quoteEl = getQuoteElement(); if (quoteEl) return quoteEl.getBoundingClientRect(); return trigger.getBoundingClientRect(); } function getPanelTargetWidth() { const quoteEl = getQuoteElement(); if (quoteEl) { const w = Math.round(quoteEl.getBoundingClientRect().width || 0); if (w > 0) return Math.max(ITEM_W, w); } // 未打开回复面板时,使用与回复面板一致的初始宽度:min(90vw, 820px) // (与 replaceRightSidebar 中 .qp-stack 的初始宽度保持一致) const replyInitW = Math.round(Math.min(window.innerWidth * 0.9, 820)); return Math.max(ITEM_W, replyInitW); } function positionPanel() { const triggerRect = trigger.getBoundingClientRect(); const quoteEl = getQuoteElement(); const anchorRect = getPanelAnchorRect(); const margin = 6; // 宽度跟随 qp-quote,并限制在可视区域内(窗口变窄时同步变窄) const targetW = getPanelTargetWidth(); const maxW = Math.max(ITEM_W, window.innerWidth - margin * 2); const finalW = Math.min(targetW, maxW); // 写入会脏化布局,紧随其后的 getBoundingClientRect 就退化成强制同步重排;值未变则不写 const nextW = `${Math.round(finalW)}px`; if (panel.style.width !== nextW) panel.style.width = nextW; // 若当前不可见,临时显示用于测量 const wasHidden = (panel.style.display === 'none' || panel.style.display === ''); if (wasHidden) { panel.style.display = 'grid'; panel.style.visibility = 'hidden'; } const panelRect = panel.getBoundingClientRect(); const panelW = panelRect.width; const panelH = panelRect.height; // 横向:有 qp-quote 时跟随其左边缘;无 qp-quote 时居中 let left = quoteEl ? anchorRect.left : (window.innerWidth - panelW) / 2; let top = triggerRect.top - panelH - 6; if (left + panelW > window.innerWidth - margin) { left = window.innerWidth - margin - panelW; } if (left < margin) left = margin; if (top < margin) { top = triggerRect.bottom + 6; } if (top + panelH > window.innerHeight - margin) { top = Math.max(margin, window.innerHeight - margin - panelH); } const nextLeft = `${Math.round(left)}px`; const nextTop = `${Math.round(top)}px`; if (panel.style.left !== nextLeft) panel.style.left = nextLeft; if (panel.style.top !== nextTop) panel.style.top = nextTop; if (wasHidden) { panel.style.visibility = ''; } } let followRafId = 0; let followResizeHandler = null; let followScrollHandler = null; function startPanelFollow() { stopPanelFollow(); followResizeHandler = () => { if (panel.style.display === 'grid') positionPanel(); }; followScrollHandler = () => { if (panel.style.display === 'grid') positionPanel(); }; window.addEventListener('resize', followResizeHandler); // 捕获阶段监听,兼容任意滚动容器 window.addEventListener('scroll', followScrollHandler, true); const tick = () => { if (panel.style.display !== 'grid') { followRafId = 0; return; } positionPanel(); followRafId = window.requestAnimationFrame(tick); }; followRafId = window.requestAnimationFrame(tick); } function stopPanelFollow() { if (followRafId) { window.cancelAnimationFrame(followRafId); followRafId = 0; } if (followResizeHandler) { window.removeEventListener('resize', followResizeHandler); followResizeHandler = null; } if (followScrollHandler) { window.removeEventListener('scroll', followScrollHandler, true); followScrollHandler = null; } } function showPanel() { // 每次打开都按最新统计与排序模式重建,确保“关闭后再开”顺序实时更新 renderPanelItems(); positionPanel(); panel.style.display = 'grid'; panel.style.visibility = ''; panel.scrollTop = 0; bindGlobalClose(); startPanelFollow(); initKeyboardNav(); // 👇 添加:延迟聚焦,确保 DOM 渲染完成 setTimeout(() => { const items = panel.querySelectorAll('.kaomoji-item'); if (items.length > 0) { items[0].focus(); } }, 0); } function hidePanel() { panel.style.display = 'none'; unbindGlobalClose(); stopPanelFollow(); removeKeyboardNav(); } // 点击快捷排序前先关闭颜文字面板,避免用户眼前发生重排 quickSort.addEventListener('mousedown', () => { if (panel.style.display === 'grid') hidePanel(); }, true); quickSort.addEventListener('change', () => { const mode = quickSort.value || 'default'; try { if (SettingPanel && SettingPanel.state) SettingPanel.state.kaomojiSort = mode; } catch (e) {} try { const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); cfg.kaomojiSort = mode; GM_setValue(SettingPanel.key, cfg); } catch (e) {} // 与设置面板中的下拉同步 const panelSort = document.querySelector('#sp_kaomojiSort'); if (panelSort) panelSort.value = mode; // 与其他快捷下拉同步 document.querySelectorAll('.sp_kaomojiSort_copy').forEach(el => { if (el !== quickSort) el.value = mode; }); // 即时生效 document.querySelectorAll(SELECTOR).forEach(sel => { try { sel.dispatchEvent(new Event('kaomoji:sort-changed')); } catch (e) {} }); // 广播模式变化(供后续新建的复制下拉同步) try { window.dispatchEvent(new CustomEvent('kaomoji:sort-mode-changed', { detail: { mode, source: 'quick' } })); } catch (e) {} }); const onSortModeChanged = (ev) => { const mode = ev?.detail?.mode || getSortMode(); if (quickSort.value !== mode) quickSort.value = mode; }; window.addEventListener('kaomoji:sort-mode-changed', onSortModeChanged); trigger.addEventListener('click', (e) => { e.stopPropagation(); if (panel.style.display === 'none' || panel.style.display === '') { showPanel(); } else { hidePanel(); } }); let outsideHandler, escHandler; function bindGlobalClose() { outsideHandler = (e) => { // 👇 忽略由键盘触发的伪鼠标事件 if (e.pointerType === '' || e.detail === 0) { return; } // 捕获阶段执行,防止被其他脚本阻止 const target = e.target; if (!panel.contains(target) && target !== trigger) { hidePanel(); } }; escHandler = (e) => { if (e.key === 'Escape') { e.preventDefault(); hidePanel(); } }; // 用捕获阶段监听 click 和 mousedown document.addEventListener('click', outsideHandler, true); document.addEventListener('mousedown', outsideHandler, true); window.addEventListener('keydown', escHandler); } function unbindGlobalClose() { document.removeEventListener('click', outsideHandler, true); document.removeEventListener('mousedown', outsideHandler, true); window.removeEventListener('keydown', escHandler); } let currentIndex = -1; let keyboardHandler; //let highlightedItems = new Set(); function clearActive(items) { items.forEach(item => item.classList.remove('kaomoji-active')); } function setActive(items, index) { if (!items.length) return; const next = Math.max(0, Math.min(index, items.length - 1)); if (currentIndex === next && items[currentIndex] && items[currentIndex].classList.contains('kaomoji-active')) { return; } if (currentIndex >= 0 && items[currentIndex]) { items[currentIndex].classList.remove('kaomoji-active'); } currentIndex = next; const el = items[currentIndex]; el.classList.add('kaomoji-active'); el.focus({ preventScroll: true }); el.scrollIntoView({ block: 'nearest', behavior: 'auto' }); } function initKeyboardNav() { const items = Array.from(panel.querySelectorAll('.kaomoji-item')); if (!items.length) return; // 重置状态 clearActive(items); currentIndex = -1; setActive(items, 0); keyboardHandler = (e) => { // 如果面板不可见,直接返回 if (panel.style.display === 'none' || panel.style.display === '') { return; } const items = Array.from(panel.querySelectorAll('.kaomoji-item')); if (!items.length) return; // const panelWidth = panel.offsetWidth - PAD * 2; // const cols = Math.floor(panelWidth / (ITEM_W + GAP)); // // 使用实际 grid 列数,避免计算误差 // const gridCols = getComputedStyle(panel).gridTemplateColumns.split(/\s+/).filter(s => s && s !== '').length; // const cols = gridCols > 0 ? gridCols : 1; // 通过实际位置计算列数 // let cols = items.length; // 默认只有一行 // if (items.length > 1) { // const firstTop = items[0].getBoundingClientRect().top; // for (let i = 1; i < items.length; i++) { // if (items[i].getBoundingClientRect().top > firstTop) { // cols = i; // break; // } // } // } // 通过首行实际 offsetTop 统计列数,避免两行颜文字导致行高变化影响判定 let cols = 1; // 默认至少一列 if (items.length > 1) { const firstTop = items[0].offsetTop; let count = 1; for (let i = 1; i < items.length; i++) { if (Math.abs(items[i].offsetTop - firstTop) <= 1) { count++; } else { break; } } cols = Math.max(1, count); } let newIndex = currentIndex; const key = e.key.toLowerCase(); if (e.key === 'ArrowRight' || key === 'd') { e.preventDefault(); newIndex = Math.min(currentIndex + 1, items.length - 1); } else if (e.key === 'ArrowLeft' || key === 'a') { e.preventDefault(); newIndex = Math.max(currentIndex - 1, 0); } else if (e.key === 'ArrowDown' || key === 's') { e.preventDefault(); newIndex = Math.min(currentIndex + cols, items.length - 1); } else if (e.key === 'ArrowUp' || key === 'w') { e.preventDefault(); newIndex = Math.max(currentIndex - cols, 0); } else if (e.key === 'Enter' || key === ' ') { e.preventDefault(); items[currentIndex].click(); return; } else if (key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey) { // 焦点在面板内时,按 C 复制当前高亮颜文字(与右键复制一致) const activeEl = document.activeElement; if (panel.contains(activeEl) && items[currentIndex] && items[currentIndex].dataset && items[currentIndex].dataset.value) { e.preventDefault(); copyKaomojiValue(items[currentIndex].dataset.value); } return; } if (newIndex !== currentIndex) { setActive(items, newIndex); } }; window.addEventListener('keydown', keyboardHandler); } function removeKeyboardNav() { if (keyboardHandler) { window.removeEventListener('keydown', keyboardHandler); keyboardHandler = null; } // 清除所有颜文字的高亮样式 const items = panel.querySelectorAll('.kaomoji-item'); items.forEach(item => item.classList.remove('kaomoji-active')); currentIndex = -1; } select.addEventListener('kaomoji:updated', () => { renderPanelItems(); }); select.addEventListener('kaomoji:sort-changed', () => { renderPanelItems(); if (panel.style.display === 'grid') { initKeyboardNav(); } }); }); } /** * 功能 3:颜文字样式拓展 */ function extendKaomojiSet() { const EXTRA_EMOTS = [ "( ´_ゝ`)旦","(<ゝω・) ☆","(`ε´ (つ*⊂)","=͟͟͞͞( 'ヮ' 三 'ヮ' =͟͟͞͞)","↙(`ヮ´ )↗ 开摆!", "(っ˘Д˘)ノ<","(ノ#)`д´)σ","₍₍(ง`ᝫ´ )ว⁾","( `ᵂ´)","( *・ω・)✄╰ひ╯","U•ェ•*U","⊂( ゚ω゚)つ", "( ゚∀。)7","・゚( ゚∀。) ゚。","\\( ゚∀。)/","(╬゚∀。)","( `д´)σ","( ゚ᯅ 。)","( ;`д´; )","m9( `д´)","( ゚π。)","ᕕ( ゚∀。)ᕗ","ᕙ( ゚∀。)ᕗ", "ฅ(^ω^ฅ)","(|||^ヮ^)","(|||ˇヮˇ)","( ↺ω↺)"," `ー´) `д´) `д´)", "₍˄·͈༝·͈˄₎◞","⁽ ˇᐜˇ⁾","⁽ ˆ꒳ˆ⁾","⁽ ^ᐜ^⁾","⁽´°`⁾","⁽´ᵖ`⁾","⁽ ˙³˙⁾","⁽°ᵛ°⁾","⁽ `ᵂ´⁾", "( ;ˇωˇ; )","( ‸ო‸)","( ‸ω‸)","( ^ω。)","/̵͇̿̿/’̿’̿ ̿ ̿̿ ̿̿ ̿̿","( ;´ω`)人 ","_(:зゝ∠)_","( ゚ 灬゚)","( `д´)7","(ᗜᴗᗜ)","(~゚ω゚)~", "接☆龙☆大☆成☆功","ᑭ`д´)ᓀ ∑ᑭ(`ヮ´ )ᑫ","乚 (^ω^ ミэ)Э好钩我咬","乚(`ヮ´ ミэ)Э","( ゚∀。ミэ)Э三三三三 乚", "(ˇωˇ ミэ)Э三三三三 乚","(‸ω‸ ミэ)Э","( へ ゚∀゚)べ摔低低","(ベ ˇωˇ)べ 摔低低", ]; const EXTRA_RICH = { "҉( ゚∀。)": " ҉\n( ゚∀。)", "⬭꒰ঌ( ˇωˇ)໒꒱":" ⬭\n꒰ঌ( ˇωˇ)໒꒱", "齐齐蛤尔": "(`ヮ´ )σ`∀´) ゚∀゚)σ", "呼伦悲尔": "( ノд`゚);´д`) ´_ゝ`)", "愕尔多厮": "Σ( ゚д゚)´゚Д゚) ゚д゚)))", "智利": "( ゚∀。)∀。)∀。)", "阴山山脉": "( ˇωˇ )◕∀◕。)^ω^)", "mini阴阳酱":"₍˄·͈༝·͈˄₎◞⁽ ˇᐜˇ⁾⁽ ˆ꒳ˆ⁾⁽ ^ᐜ^⁾⁽´°`⁾⁽´ᵖ`⁾⁽ ˙³˙⁾⁽°ᵛ°⁾⁽ `ᵂ´⁾", "F5欧拉": " σ σ\nσ( ´ρ`)σ[F5]\n σ σ", "UK酱": "\\ ︵\nᐕ)⁾⁾", "白羊": "╭◜◝ ͡ ◜◝ J J\n( `д´) “咩!”\n╰◟д ◞ ͜ ◟д◞", "兔兔": " /) /)\nc( ╹^╹)", "neko": " ∧,, \n ヾ `. 、`フ\n (,`'´ヽ、、ヅ\n (ヽv' `''゙つ\n ,ゝ ⌒`y'''´\n ( (´^ヽこつ\n ) )\n (ノ", "neko2": " 、ゞヾ∧\"\"'∧;,\n ヾ ・ω・ 彡\n ミ o o ミ\n ~彡 ミ\n /ソ,, ,0; ,;;:、0ヾ`", "neko3": " /l、 \n(*´∀`)σ(゚∀。7\n l、 ~ヽ\n じしf_, )ノ ", "给你": "(\\_/)\n(・_・)\n / >", "催更喵gkd": " ___\n /> フ\n | _ _ l 我是一只催更的\n /` ミ_xノ 喵喵酱\n / | gkdgkd\n / ヽ ノ\n │ | | |\n / ̄| | | |\n | ( ̄ヽ__ヽ_)__)\n \二つ", "举高高": " _∧_∧_ \n ((∀`/ ) \n /⌒ / \n /(__ノ\_ノ \n (_ノ ||| 举高高~~\n ∧_∧ ∧_∧\n(( ・∀・ ))・∀・) )\n`\ ∧ ノ\n / |/ |\n(_ノ_)_ノL_)", "举高高2": " _∧_∧_\n ((∀`/ )\n /⌒ /\n /(__ノ\_ノ(((ヽ\n (_ノ  ̄Y\\n| (\ (\\ /) | )举高高!\nヽ ヽ` ( ゚∀゚ ) _ノ /\n \ | ⌒Y⌒ / /\n |ヽ · | · ノ //\n \トー仝ーイ\n | ミ土彡/\n ). \\ ° /\n ( \\. y \\", "举糕糕": "举糕糕~\n ☆☆☆☆☆☆☆☆\n ╭┻┻┻┻┻┻┻┻╮\n ┃╱╲╱╲╱╲╱╲┃\n╭┻━━━━━━━━┻╮\n┃╱╲╱╲╱╲╱╲╱╲┃\n┗━━━━━━━━━━┛\n ∧_∧ ∧_∧\n (( ・∀・ ))・∀・) )\n `\ ∧ ノ\n / |/ |\n (_ノ_)_ノL_)", "Happy肥肥Day": ". .★ * ★.. \n .*★ *. *..* ★ \n ★ ★ \n ‘*. *' ʜᴀᴘᴘʏ 肥肥 ᴅᴀʏ \n ‘★. ★’ \n *..★\n┊┊┊┊☆☆☆☆☆☆☆☆┊┊┊┊\n┊┊┊╭┻┻┻┻┻┻┻┻╮┊┊┊\n┊┊┊┃╱╲╱╲╱╲╱╲┃┊┊┊\n┊┊╭┻━━━━━━━━┻╮┊┊\n┊┊┃╱╲╱╲╱╲╱╲╱╲┃┊┊\n╱▔┗━━━━━━━━━━┛▔╲", "大嘘": "吁~~~~ rnm,退钱!\n / /\n( ゚ 3゚) `ー´) `д´) `д´)", "巴拉巴拉": " ∧_∧\n(。・ω・。)つ━☆・*。\n⊂ ノ ・゜+.\n しーJ °。+ *´¨)\n .· ´¸.·*´¨) ¸.·*¨)\n (¸.·´ (¸.·’*", "碣石": " _ _\n ( ゚_゚) \n/ (⁰ )╲/", "冰封王座": "(ノ゚∀゚)ノ👑\n ( ゚∀。)\n\n 👑\n(//゚ω゚)//\n \n 👑\n(Ⅱ゚ω゚)Ⅱ\n\n 👑\nᕕ( ᐛ )ᕗ", "冰封王座2": "(ノ゚∀゚)ノ🍟\n ( ゚∀。)\n\n 🍟\n(//゚ω゚)//\n \n 🍟\n(Ⅱ゚ω゚)Ⅱ\n\n 🍟\nᕕ( ᐛ )ᕗ", "冰封王座3": "( `д´)=🔪 👑\n ( ゚ 3゚)\n 👑\n(// `ー´)//\n( x 3x)\n 👑\n(Ⅱ`∀´)Ⅱ\n( x 3x)\n 👑\n( `д´)\n( x 3x)", "喵喵酱": " ^ ^\n( =`д´=)哈—!", "狗比酱": "(U `д´)<汪汪汪!", "狼酱":" ႔ ႔\n嗷呜!> ᠸ`д´ 𐅠", "起舞":"⊂ヽ( ^ω^)つ\n \ / \n ( __フ\n (/", "N98": "淦\n是\nN\n9\n8\n接☆龙☆大☆成☆功\n盗摄?( ^ω^)\n石雕\n要素齐全\nN98是什么?\n同问\n感谢\n谢谢\n要素齐全\n什么是要素齐全?\n淦\n是\nN\n9\n5\nr\n警惕r点机器人\n你一个人都说完了我们说什么(╬゚д゚)", "望po石":" ┏━┓\n ┃望┃\nᕕ┃po┃ᕗ\n ┃石┃\n ┗━┛\n嗨呀我又来望po了\n我天天都来这望po", "望po石2": " ┏━┓\n ┃望┃\n ┃po┃\n ┃石┃\n ┗━┛ \n ┏━━━┓\n ┏┛ 望 ┗┓\n ┏┛ po ┗┓\n┏┛ 山 ┗┓\n┗━━━━━━━━━┛", "撞墙": "┃電柱┃ ( ´ー`)\n┃電柱┃д゚ ) =͟͟͞͞ =͟͟͞͞\n┃電柱┃ ( ´д`)\n┃電柱┃д゚ ) =͟͟͞͞ =͟͟͞͞\n┃電柱┃ (;´Д`)\n┃電柱┃π。) =͟͟͞͞ =͟͟͞͞", "冰箱先生":" /\n┌───┐\n│ ゚∀゚│\n├─┬─┤\n│ │ │\n└─┴─┘", "冰箱先生3D":" /\n▁▁▁▁\n╲ ╲\n▏┌───┐\n▏│ ゚∀゚ │\n▏├─┬─┤\n╲│ │ │\n └─┴─┘", "血压↑":" 他妈的/>__フ\n | = ^ω^)= 血压↗↗\n /` 丨", "血压0↓":" 算逑喽/>__フ\n | = ˇωˇ)= 血压0↓\n /` 丨", "挺好颜":"╯ 乀\nヘ へ\n ′\n ﹀", "全角空格": " ", "零宽空格": "", }; const ORDERED_RICH = [ "҉( ゚∀。)","⬭꒰ঌ( ˇωˇ)໒꒱","齐齐蛤尔","呼伦悲尔","愕尔多厮","智利","阴山山脉", "mini阴阳酱","F5欧拉","UK酱","白羊","兔兔", "neko","neko2","neko3","催更喵gkd", "给你","举高高","举高高2","举糕糕","Happy肥肥Day", "大嘘","巴拉巴拉","碣石", "冰封王座","冰封王座2","冰封王座3", "喵喵酱","狗比酱","狼酱","起舞","N98", "望po石","望po石2","撞墙","冰箱先生","冰箱先生3D","血压↑","血压0↓","挺好颜", "全角空格","零宽空格", // 页面中“防剧透/骰子/高级骰子”不动其原位 ]; const NEED_LF = new Set([ "҉( ゚∀。)","⬭꒰ঌ( ˇωˇ)໒꒱","F5欧拉","UK酱","白羊","兔兔", "neko","neko2","neko3","催更喵gkd", "给你","举高高","举高高2","举糕糕","Happy肥肥Day", "大嘘","巴拉巴拉","碣石", "冰封王座","冰封王座2","冰封王座3", "喵喵酱","狗比酱","狼酱","起舞","N98", "望po石","望po石2","撞墙","冰箱先生","冰箱先生3D","血压↑","血压0↓","挺好颜", ]); // 一次性补齐(选择器就绪且已有至少一个选项时调用) function patchSelect(sel) { if (!sel || sel.dataset.kaoExtended === '1') return; if (!sel.options || sel.options.length === 0) return; // 去重集合 const existingValues = new Set(); const existingLabels = new Set(); Array.from(sel.options).forEach(op => { existingValues.add(op.value); existingLabels.add(op.textContent); }); // 追加普通 const frag = document.createDocumentFragment(); EXTRA_EMOTS.forEach(txt => { if (!existingValues.has(txt)) { const op = new Option(txt, txt); op.dataset.kaoKey = `extra:${txt}`; frag.appendChild(op); existingValues.add(txt); } }); if (frag.childNodes.length) sel.appendChild(frag); // 收集需要重排的富颜文字:先把 ORDERED_RICH 中已存在的挪出 const bucket = new Map(); ORDERED_RICH.forEach(k => bucket.set(k, null)); for (let i = sel.options.length - 1; i >= 0; i--) { const op = sel.options[i]; const label = op.textContent; if (bucket.has(label)) { bucket.set(label, op); sel.remove(i); } } // 按顺序插回;缺的用 EXTRA_RICH 补齐;值前加换行(NEED_LF) const richFrag = document.createDocumentFragment(); ORDERED_RICH.forEach(key => { let node = bucket.get(key); if (node) { node.dataset.kaoKey = `rich:${key}`; richFrag.appendChild(node); } else if (EXTRA_RICH[key]) { const val = NEED_LF.has(key) ? ("\n" + EXTRA_RICH[key] + "\n") : EXTRA_RICH[key]; const op = new Option(key, val); op.dataset.kaoKey = `rich:${key}`; richFrag.appendChild(op); } }); if (richFrag.childNodes.length) sel.appendChild(richFrag); sel.dataset.kaoExtended = '1'; sel.dispatchEvent(new CustomEvent('kaomoji:updated', { bubbles: true })); } function patchSelectsIn(root = document) { if (!root) return; const selects = []; if (root.matches && root.matches(SELECTOR)) selects.push(root); if (root.querySelectorAll) root.querySelectorAll(SELECTOR).forEach(sel => selects.push(sel)); selects.forEach(sel => { if (sel.options && sel.options.length > 0) patchSelect(sel); }); } // 方案 A:钩住 jQuery 的 append,仅针对 #h-emot-select (function hookjQueryAppend(){ const $ = window.jQuery; if (!$ || !$.fn || !$.fn.append || $.fn.append.__kaoHooked) return; const rawAppend = $.fn.append; $.fn.append = function(...args){ const ret = rawAppend.apply(this, args); try { // 如果目标包含我们的 select,就尝试打补丁 this.each(function(){ patchSelectsIn(this); }); } catch(_) {} return ret; }; $.fn.append.__kaoHooked = true; })(); // 方案 B:用 MutationObserver 监听 select 的子节点变化,首次填充后补齐 (function observeSelect(){ document.querySelectorAll(SELECTOR).forEach(sel => { if (sel.options && sel.options.length > 0) { patchSelect(sel); return; } if (sel.dataset.kaoOptionObserver === '1') return; sel.dataset.kaoOptionObserver = '1'; const mo = new MutationObserver(() => { if (sel.options && sel.options.length > 0) { patchSelect(sel); mo.disconnect(); delete sel.dataset.kaoOptionObserver; scheduleKaomojiEnhancePass(); } }); mo.observe(sel, { childList: true, subtree: false }); }); })(); // 方案 C:兜底重试(避免异步加载错过时机) let tries = 0; (function retry(){ patchSelectsIn(document); const pending = Array.from(document.querySelectorAll(SELECTOR)).some(sel => !sel.dataset.kaoExtended); if (!pending) return; if (tries++ < 30) { setTimeout(retry, 100); } })(); } } // function renderSpoiler(container) { // var $container = $(container); // // 用构造函数写法,避免 /^.../ 形式 // var reg = new RegExp("\\[h\\]([\\s\\S]*?)\\[\\/h\\]", "gi"); // $container.find('.h-threads-content').each(function () { // var text = $(this).text().trim(); // var match = reg.exec(text); // if (match) { // $(this).html('<span class="h-hidden-text">' + match[1] + '</span>'); // } // }); // } /* -------------------------------------------------- * tag 14. ‘增强x岛匿名版’:添加预览框+草稿保存/恢复/自动设置网页标题/人类友好时间显示/引用追记/粘贴图片上传 * -------------------------------------------------- */ // 统一生成草稿存储用的 key // 统一:草稿 key 生成 // ★ 草稿键规范化:草稿是「串级」共享数据,同一串在任何页面/任何页码回复都读写同一槽。 // /t/{tid}、/t/{tid}/page/N、/Forum/po/id/{tid}/... → 统一为 '/t/{tid}' // 非串内页(/f/xxx 板块页、时间线、搜索页等)→ 返回空串,草稿功能整体短路 // (只有串内才有草稿:板块页发串表单不保存/不载入草稿) function getDraftKey() { return normalizeDraftKeyFromPath(window.location.pathname); } function normalizeDraftKeyFromPath(pathname) { const path = String(pathname || ''); const parsed = parseThreadHistoryUrl(path); if (!parsed || !parsed.threadId) return ''; return '/t/' + parsed.threadId; } function getDraftStorageKey(pathname = getDraftKey()) { return `xdex_draft:${pathname}`; } function getDraftRegistryKey() { return 'xdex_draft_registry'; } function isLegacyDraftKey(key) { return /^\/t\/\d+$/.test(key) || /^\/f\/[^/]+$/.test(key) || /^\/Forum\/timeline\/id\/\d+$/.test(key); } function getDraftRegistry() { try { if (typeof GM_getValue === 'function') { const value = GM_getValue(getDraftRegistryKey(), []); if (!Array.isArray(value)) return []; // 惰性自愈: 值为空的内容不保留——避免旧版本留存的空草稿键导致存储膨胀 const clean = value.filter((key) => { try { const v = (typeof GM_getValue === 'function') ? GM_getValue(key, '') : ''; return typeof v === 'string' && v !== ''; } catch (_) { return false; } }); if (clean.length !== value.length) { try { GM_setValue(getDraftRegistryKey(), clean); } catch (_) {} value.forEach((key) => { if (clean.indexOf(key) === -1) { try { if (typeof GM_deleteValue === 'function') GM_deleteValue(key); else GM_setValue(key, ''); } catch (_) {} } }); } return clean; } } catch (_) {} return []; } function saveDraftRegistry(list) { try { if (typeof GM_setValue === 'function') { GM_setValue(getDraftRegistryKey(), Array.from(new Set(Array.isArray(list) ? list : []))); } } catch (_) {} } function addDraftKeyToRegistry(storageKey) { if (!storageKey) return; const registry = getDraftRegistry(); if (!registry.includes(storageKey)) { registry.push(storageKey); saveDraftRegistry(registry); } } function removeDraftKeyFromRegistry(storageKey) { if (!storageKey) return; const next = getDraftRegistry().filter((key) => key !== storageKey); saveDraftRegistry(next); } function getDraftEnabledNow() { try { if (typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state) { return !!SettingPanel.state.enableDraft; } if (typeof GM_getValue === 'function') { const saved = GM_getValue(SettingPanel.key, {}); return !!Object.assign({}, SettingPanel.defaults, saved).enableDraft; } } catch (_) {} return true; } function getLegacyDraftValue(pathname = getDraftKey()) { try { if (typeof GM_getValue === 'function') { const value = GM_getValue(pathname, ''); return typeof value === 'string' ? value : ''; } } catch (_) {} return ''; } function readDraftValue(pathname = getDraftKey()) { const storageKey = getDraftStorageKey(pathname); let value = ''; try { if (typeof GM_getValue === 'function') { value = GM_getValue(storageKey, ''); } } catch (_) {} if (typeof value === 'string' && value !== '') return value; return getLegacyDraftValue(pathname); } function saveDraftValue(pathname, content) { if (!getDraftEnabledNow()) return; const storageKey = getDraftStorageKey(pathname); // 正文清空等于删除草稿: 删除本地并登记账本, 防止云端旧草稿经同步复活 if (String(content == null ? '' : content).trim() === '') { deleteDraftSafe(storageKey); return; } try { if (typeof GM_setValue === 'function') { GM_setValue(storageKey, content); addDraftKeyToRegistry(storageKey); removeDraftDeletion(storageKey); // 重新编辑即翻案: 撤销该 URL 的删除标记 } } catch (_) {} } function migrateLegacyDraftIfNeeded(pathname = getDraftKey()) { const legacyValue = getLegacyDraftValue(pathname); if (typeof legacyValue !== 'string' || legacyValue === '') return ''; const storageKey = getDraftStorageKey(pathname); let current = ''; try { if (typeof GM_getValue === 'function') { current = GM_getValue(storageKey, ''); } } catch (_) {} if (!current) { try { if (typeof GM_setValue === 'function') { GM_setValue(storageKey, legacyValue); addDraftKeyToRegistry(storageKey); } } catch (_) {} } deleteDraftSafe(pathname); return legacyValue; } // ★ 草稿槽规范化迁移:历史版本按 location.pathname 存草稿,同一串产生多个槽 //(/t/{tid}、/t/{tid}/page/N、/Forum/po/id/{tid}/page/N 各一份)。本函数把 // 所有旧槽合并进规范槽 '/t/{tid}': // - 规范槽已有内容 → 保留;否则取「registry 中位置最靠后」(最近首次保存)的非空旧槽 // - 被清理的旧槽:GM_deleteValue + 登记删除账本(防 WebDAV 同步复活) // - 非串内页旧槽(/f/xxx、时间线等):草稿功能已不覆盖这些页面,直接清理(含空值槽) // 仅在首次调用时执行一次(GM 标记);失败静默不阻塞。 function migrateDraftSlotsToCanonical() { try { if (GM_getValue('xdex_draft_slots_migrated_v1', false)) return; const registry = getDraftRegistry(); const keys = new Set(registry.slice()); try { if (typeof GM_listValues === 'function') { GM_listValues().forEach((k) => { if (typeof k === 'string' && k.indexOf('xdex_draft:') === 0) keys.add(k); }); } } catch (_) {} // 按规范槽分组(registry 顺序保序:越靠后越新) const groups = new Map(); // canonical -> [{storageKey, value}] keys.forEach((storageKey) => { const pathname = String(storageKey || '').slice('xdex_draft:'.length); if (!pathname) return; let value = ''; try { value = GM_getValue(storageKey, ''); } catch (_) { value = ''; } if (typeof value !== 'string') value = ''; const canonical = normalizeDraftKeyFromPath(pathname); const target = canonical || ''; // 非串内页 → 空组(统一清理) if (!groups.has(target)) groups.set(target, []); groups.get(target).push({ storageKey, value, pathname }); }); const registrySet = new Set(registry); let changed = false; groups.forEach((slots, canonical) => { if (!canonical) { // 非串内页槽:全部清理(空值或历史残留都清) slots.forEach((slot) => { try { deleteDraftSafe(slot.storageKey); } catch (_) {} registrySet.delete(slot.storageKey); changed = true; }); return; } const canonicalStorageKey = getDraftStorageKey(canonical); let canonicalValue = ''; try { canonicalValue = GM_getValue(canonicalStorageKey, ''); } catch (_) {} if (typeof canonicalValue !== 'string') canonicalValue = ''; // 合并:规范槽优先;否则取 registry 顺序最靠后的非空槽(最近保存) let picked = canonicalValue ? null : null; let pickedFrom = canonicalStorageKey; if (!canonicalValue) { for (let i = slots.length - 1; i >= 0; i--) { if (slots[i].value) { picked = slots[i]; pickedFrom = slots[i].storageKey; break; } } } const finalValue = canonicalValue || (picked ? picked.value : ''); if (finalValue && !canonicalValue) { try { GM_setValue(canonicalStorageKey, finalValue); registrySet.add(canonicalStorageKey); removeDraftDeletion(canonicalStorageKey); changed = true; } catch (_) {} } // 清理所有非规范槽 slots.forEach((slot) => { if (slot.storageKey === canonicalStorageKey) return; try { deleteDraftSafe(slot.storageKey); } catch (_) {} registrySet.delete(slot.storageKey); changed = true; }); }); // 清理 registry 中已无值的键 const nextRegistry = Array.from(registrySet).filter((k) => { try { const v = GM_getValue(k, ''); return typeof v === 'string' && v !== ''; } catch (_) { return false; } }); if (changed || nextRegistry.length !== registry.length) saveDraftRegistry(nextRegistry); GM_setValue('xdex_draft_slots_migrated_v1', true); if (changed) console.info('[X岛-EX][草稿] 槽位规范化迁移完成'); } catch (e) { console.warn('[X岛-EX][草稿] 槽位迁移失败', e); } } // 统一:安全删除草稿(有 GM_deleteValue 用之;否则写空串兜底) // ── 草稿删除账本: WebDAV 双向同步时传播"草稿被删除"状态 ── // 语义与分组设置删除账本一致: 删除总是赢, 重新编辑即翻案 function getDraftDeletionStore() { try { const v = GM_getValue('xdex_draft_deletions', null); return (v && typeof v === 'object') ? v : {}; } catch (_) { return {}; } } function canonicalDraftKey(anyKey) { const s = String(anyKey || ''); if (!s) return ''; return s.indexOf('xdex_draft:') === 0 ? s : getDraftStorageKey(s); } function registerDraftDeletion(storageKey) { const key = canonicalDraftKey(storageKey); if (!key) return; const store = getDraftDeletionStore(); store[key] = Math.max(Number(store[key]) || 0, Date.now()); try { GM_setValue('xdex_draft_deletions', store); } catch (_) {} } function removeDraftDeletion(storageKey) { const key = canonicalDraftKey(storageKey); if (!key) return; const store = getDraftDeletionStore(); if (!(key in store)) return; delete store[key]; try { GM_setValue('xdex_draft_deletions', store); } catch (_) {} } function deleteDraftSafe(key) { if (!key) key = getDraftKey(); if (!key) return; // 非串内页(草稿键为空)无草稿可删,避免空键误删/空账本登记 try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(key); } else if (typeof GM_setValue === 'function') { GM_setValue(key, ''); // 覆盖为空字符串 } else { // 无 GM_* 时不做处理 } } catch (_) {} removeDraftKeyFromRegistry(key); registerDraftDeletion(key); // 标记已删除, 供 WebDAV 同步压制远端旧草稿 } function deleteAllDraftsSafe() { const keysToDelete = new Set(getDraftRegistry()); try { keysToDelete.add(getDraftStorageKey()); } catch (_) {} try { if (typeof GM_listValues === 'function') { const allKeys = GM_listValues(); allKeys.forEach((key) => { if (!isLegacyDraftKey(key)) return; let value = ''; try { value = GM_getValue(key, ''); } catch (_) {} if (typeof value === 'string') keysToDelete.add(key); }); } } catch (_) {} keysToDelete.forEach((key) => { try { deleteDraftSafe(key); } catch (_) {} }); saveDraftRegistry([]); } // 完整移植为可调用函数。需要:jQuery 2.2.4+;GM_setValue/GM_getValue/GM_deleteValue 授权 function enhanceIsland(config = {}) { return startupPerfDebug.measure('enhanceIsland', () => { // 配置开关(默认全开) let cfg = Object.assign({ enablePreview: true, // 发帖预览(插入预览DOM并实时渲染) enableDraft: true, // 草稿保存/恢复和成功后清理 enableAutoTitle: true, // 自动设置网页标题(含页码) enableRelativeTime: true, // 相对时间显示(每2.5秒刷新) enableQuoteInsert: true, // 点击 No.xxxx 插入引用 enablePasteImage: true // 粘贴剪贴板图片到文件输入 }, config); // 解析 jQuery const $ = cfg.$ || window.jQuery || window.$; if (!$) { console.warn('[enhanceIsland] jQuery not found.'); return; } // 公用变量 const 正文框 = $('textarea.h-post-form-textarea'); const search = window.location.search; const 搜索参数 = {}; search.replace(/^\?/, '').split('&').forEach(kev => { if (!kev) return; const [k, v] = kev.split('=', 2); 搜索参数[k] = v; }); const 路径 = window.location.pathname; const 路径分块 = 路径.split('/').splice(1); const isDraftEnabled = () => getDraftEnabledNow(); let draftAutosaveBound = false; // 预览区域 DOM const previewHtml = buildEnhanceIslandPreviewHtml(true); //previewBox.outerHTML = previewHtml; // 引用插入函数(与原脚本一致) function enhanceNode(root) { if (typeof extendQuote === 'function') extendQuote(root); if (typeof initExtendedContent === 'function') initExtendedContent(root); if (typeof initContent === 'function') initContent(root); //if (typeof autoHideRefView === 'function') autoHideRefView(root); } // 只有在页面存在发帖表单容器时才插入预览 function initPreviewBox() { if (!cfg.enablePreview) return; if (!$('#h-post-form form').length) return; // 只创建一次预览框;early skeleton 已存在时,替换为完整可绑定版本 const $existingEarlyBox = $('.h-preview-box[data-xdex-early-preview="1"]').first(); if (!$('.h-preview-box').length || $existingEarlyBox.length) { let $box; if ($existingEarlyBox.length) { $box = $(previewHtml).removeAttr('data-xdex-early-preview'); $existingEarlyBox.replaceWith($box); } else { $box = $(previewHtml).removeAttr('data-xdex-early-preview').insertAfter('#h-post-form form'); } setTimeout(() => { startupPerfDebug.measure('enhanceIsland.deferEnhanceNode', () => enhanceNode($box[0]), () => startupPerfDebug.summarizeRoot($box[0])); }, 0); // ★ 新增:让预览框里的图片也启用高清图+旋转布局逻辑 if (typeof enableHDImage === 'function') { enableHDImage($box[0]); } // 让预览框宽度跟随表单 // === 实时监测预览框父容器变化 === const boxEl = $box[0]; function applyBoxStyle(previewEl) { return startupPerfDebug.measure('enhanceIsland.applyBoxStyle', () => { if (!$box.closest('.qp-body').length) { // 不在 .qp-body 内时,应用基础样式 $box.css({ width: '100%', 'box-sizing': 'border-box' }); $box.find('.h-threads-content').css({ 'overflow-wrap': 'break-word', 'word-break': 'break-word', 'white-space': 'normal' }); } else { // 在 .qp-body 内时,实时跟随 .qp-content-wrap 宽度 const wrapEl = $box.closest('.qp-content-wrap')[0]; if (wrapEl && previewEl) { const wrapStyle = window.getComputedStyle(wrapEl); previewEl.style.width = wrapStyle.width; previewEl.style.boxSizing = 'border-box'; } // 保持换行规则 $box.find('.h-threads-content').css({ 'max-width': '100%', /* 预览框不超过容器宽度 */ 'overflow-wrap': 'break-word', 'word-break': 'break-word', 'white-space': 'normal' }); } }, () => ({ previewBoxes: document.querySelectorAll('.h-preview-box').length })); } // 初始化时执行一次 applyBoxStyle(); // 监听 DOM 变化 let previewStyleScheduled = false; const mo = new MutationObserver((mutations) => { startupPerfDebug.measureObserver('enhanceIsland.previewBoxStyle', mutations, () => { if (previewStyleScheduled) return; previewStyleScheduled = true; requestAnimationFrame(() => { previewStyleScheduled = false; applyBoxStyle(); }); }, () => ({ previewBoxes: document.querySelectorAll('.h-preview-box').length })); }); // 监听父节点变化(包括被移动到别的容器) mo.observe(document.body, { childList: true, subtree: true }); if (typeof updatePreviewCookieId === 'function') updatePreviewCookieId(); // === 图片预览更新函数 === // 预览更新统一走全局 updatePreviewImageFromFile(实时查询当前活跃预览框), // 不再用闭包捕获的旧 $box,避免 REPLY 浮窗 open() 早于 enhanceIsland // 时粘贴图片更新到看不见的框。 // === 监听文件选择 === const fileInput = document.querySelector('input[type="file"][name="image"]'); if (fileInput) { function syncPreviewFromCurrentFileInput() { const file = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null; updatePreviewImageFromFile(file); } fileInput.addEventListener('change', syncPreviewFromCurrentFileInput); // 兼容粘贴事件先把图片写入 input.files、预览监听后绑定的竞态 syncPreviewFromCurrentFileInput(); } // 粘贴图片预览:使用全局幂等绑定,确保 REPLY 浮窗 early 打开时也能捕获 bindPasteImagePreviewOnce(); } } // 预览引用/隐藏文本渲染 const previewBox = $('<div/>'); // 占位,真正引用在 initPreviewBox 后重新抓取 const refExp = /^([>>]+.*)$/; const quoteTokenExp = /(?:>>)?No\.(\d{8})\b|(?:>>)?(?<!\d)(\d{8})(?!\d)/g; const hideExp = /\[h\]([\s\S]*?)\[\/h\]/g; let lastPreviewRaw = null; let lastPreviewRenderedHtml = ''; let lastPreviewEnhanceAt = 0; let previewEnhanceTimer = null; function bindPreviewQuoteHover(previewRoot) { if (!previewRoot) return; if (typeof initContent === 'function') initContent(previewRoot); } function extendPreviewQuoteText(previewRoot) { if (!previewRoot) return; if (typeof extendQuote === 'function') extendQuote(previewRoot); bindPreviewQuoteHover(previewRoot); } function schedulePreviewEnhance(previewRoot) { if (!previewRoot) return; const now = Date.now(); const elapsed = now - lastPreviewEnhanceAt; if (previewEnhanceTimer) { clearTimeout(previewEnhanceTimer); previewEnhanceTimer = null; } if (lastPreviewEnhanceAt === 0) { lastPreviewEnhanceAt = now; previewEnhanceTimer = setTimeout(() => { previewEnhanceTimer = null; lastPreviewEnhanceAt = Date.now(); extendPreviewQuoteText(previewRoot); }, 0); return; } if (elapsed >= 3000) { lastPreviewEnhanceAt = now; extendPreviewQuoteText(previewRoot); return; } previewEnhanceTimer = setTimeout(() => { previewEnhanceTimer = null; lastPreviewEnhanceAt = Date.now(); extendPreviewQuoteText(previewRoot); }, 3000 - elapsed); } function renderContent(raw) { return startupPerfDebug.measure('enhanceIsland.renderContent', () => { const box = $('.h-preview-box'); if (!box.length) return; const previewContent = box.find('.h-threads-content'); if (raw === lastPreviewRaw) return; lastPreviewRaw = raw; if (typeof raw !== 'string' || raw.trim() === '') { previewContent.text(''); lastPreviewRenderedHtml = ''; if (previewEnhanceTimer) { clearTimeout(previewEnhanceTimer); previewEnhanceTimer = null; } try { if (typeof window.__xdexScheduleReplyPanelFit === 'function') { window.__xdexScheduleReplyPanelFit(); } } catch (_) {} return; } // 离屏拼好再一次写回,避免先清空再填充导致回复浮窗首字高度闪一下 const $frag = $('<div></div>'); previewContent[0]?.setAttribute('data-xdex-preview-spoiler-rendered', '1'); function appendPreviewText(text, className) { if (text === '') return; const span = $('<span></span>').text(text); if (className) span.addClass(className); $frag.append(span); } function appendPreviewQuoteLine(line) { $frag.append($('<font color="#789922"></font>').text(line)); } for (let i of raw.split('\n')) { i = i.replace(/ +/g, ' '); if (refExp.test(i)) { appendPreviewQuoteLine(i); } else { hideExp.lastIndex = 0; let lastIndex = 0; let match; let hasHiddenText = false; while ((match = hideExp.exec(i)) !== null) { hasHiddenText = true; appendPreviewText(i.slice(lastIndex, match.index)); appendPreviewText(match[1], 'h-hidden-text'); lastIndex = hideExp.lastIndex; } if (hasHiddenText) { appendPreviewText(i.slice(lastIndex)); } else { appendPreviewText(i); } } $frag.append('<br>'); } const renderedHtml = $frag[0]?.innerHTML || ''; const previewChanged = renderedHtml !== lastPreviewRenderedHtml; lastPreviewRenderedHtml = renderedHtml; if (previewChanged) { // 旧:previewContent.text(''); 再逐段 append // 一次替换,减少中间空高被 auto-fit 量到 previewContent.empty().append($frag.contents()); bindPreviewQuoteHover(previewContent[0]); schedulePreviewEnhance(previewContent[0]); // 预览空→有 后立刻排队贴高,避免 RO 晚到期间 overflow:auto 闪滚动条 try { if (typeof window.__xdexScheduleReplyPanelFit === 'function') { window.__xdexScheduleReplyPanelFit(); } } catch (_) {} } // 自动识别链接 if (typeof runAutoUrlLinkify === 'function') { runAutoUrlLinkify(previewContent[0]); } // 标记饼干背景色 if (typeof markAllCookies === 'function') { try { const cfg = getFilterConfig(); markAllCookies(cfg.markedGroups || [], previewContent[0]); } catch (e) {} } }, () => ({ textLength: typeof raw === 'string' ? raw.length : 0, lines: typeof raw === 'string' && raw ? raw.split('\n').length : 0, previewBoxes: document.querySelectorAll('.h-preview-box').length })); } // 草稿:发帖成功清空(拦截模式优先) function 清空编辑(key) { if (!isDraftEnabled()) return; if (!key) key = getDraftKey(); if (!key) return; // 非串内页无草稿可清 if (key) { deleteDraftSafe(getDraftStorageKey(key)); deleteDraftSafe(key); return; } // ===== 兼容原“中间页”的旧逻辑(仍保留)===== const okBox = document.getElementsByClassName('success')[0]; if (!okBox) return; if (!okBox.textContent.includes('回复成功')) return; const hrefEl = document.getElementById('href'); if (!hrefEl || !hrefEl.href) return; const m = /https?:\/\/[^/]+(\/t\/\d+)/.exec(hrefEl.href); if (!m) return; deleteDraftSafe(getDraftStorageKey(m[1])); deleteDraftSafe(m[1]); } // 统一用事件清空,缺省用 getDraftKey() document.addEventListener('replySuccess', e => { 清空编辑(e.detail?.key || getDraftKey()); }); // 草稿:载入 function 载入编辑() { if (!isDraftEnabled()) return; if (!正文框.length) return; const key = getDraftKey(); if (!key) return; // 非串内页(板块页等)无草稿 const migratedLegacy = migrateLegacyDraftIfNeeded(key); let draft = readDraftValue(key); if (!draft && migratedLegacy) draft = migratedLegacy; if (typeof draft !== 'string') draft = ''; const ta = 正文框[0]; const current = ta ? String(ta.value || '') : ''; const rId = 搜索参数 && 搜索参数.r ? String(搜索参数.r) : ''; const autoQuote = rId ? `>>No.${rId}\n` : ''; const currentTrim = current.trim(); // 仅当文本框为空,或仍是站点/脚本自动插入的单独引用行时,才允许草稿覆盖 // 避免页面未完全加载时用户已输入内容被 载入编辑 整框冲掉 const currentIsOnlyAutoQuote = !!(rId && ( current === autoQuote || currentTrim === `>>No.${rId}` )); if (currentTrim !== '' && !currentIsOnlyAutoQuote) { return; } // 关闭引用开启时:不在草稿载入阶段强制前置 r= 自动引用(与 tag27 一致) // 值班室/举报表单仍保留自动引用,方便精准举报 let shouldAutoPrependQuote = !!rId; if (shouldAutoPrependQuote) { try { const s = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); const disableAutoQuote = s.disableAutoQuote !== false; if (disableAutoQuote) { let preserveForReport = false; try { const decodedPath = decodeURIComponent(location.pathname || ''); if (/^\/f\/值班室(?:\/|$)/.test(decodedPath)) preserveForReport = true; } catch (e) {} if (!preserveForReport) { try { preserveForReport = !!document.querySelector('form[action="/Home/Forum/doReplyThread.html"] #h-report-switch, form[action="/Home/Forum/doPostThread.html"] #h-report-switch, #h-report-switch'); } catch (e) {} } if (!preserveForReport) shouldAutoPrependQuote = false; } } catch (e) {} } if (shouldAutoPrependQuote) { const quote = `>>No.${rId}\n`; if (!String(draft).startsWith(quote) && !String(draft).startsWith(`>>No.${rId}`)) { draft = quote + draft; } } // 内容没变化则不写回,减少无意义 input/草稿回写 if (current === draft) return; 正文框.val(draft); // 触发 input 事件,让字数计数器同步更新(jQuery .val() 不会触发原生事件) 正文框[0]?.dispatchEvent(new Event('input', { bubbles: true })); } // 草稿:注册自动保存 + 初始化一次保存触发(原脚本用 $(保存编辑)) function 注册自动保存编辑() { if (draftAutosaveBound) { 保存编辑(); return; } draftAutosaveBound = true; // 原有正文框监听 $('form').on('input', 保存编辑); // 颜文字选择变更时也更新预览(原生代码修改textarea值但不派发input事件) $('form').on('change', '#h-emot-select', 保存编辑); // 改为事件委托:监听 name 和 title 输入框 $(document).on('input', 'form input[name="name"], form input[name="title"]', 保存编辑); // 文档就绪时同步一次 $(保存编辑); } function 保存编辑() { return startupPerfDebug.measure('enhanceIsland.saveDraftAndPreview', () => { if (!正文框.length) return; if (isDraftEnabled()) { const draftKey = getDraftKey(); if (draftKey) saveDraftValue(draftKey, 正文框.val()); } renderContent(正文框.val()); const box = $('.h-preview-box'); if (box.length) { const previewTitle = box.find('.h-threads-info-title'); const previewEmail = box.find('.h-threads-info-email'); const titleVal = $('form input[name="title"]').val() || ''; const nameVal = $('form input[name="name"]').val() || ''; // 标题:空则隐藏,有值则显示并更新 if (titleVal.trim() === '') { previewTitle.hide().text('无标题'); // 保留默认文案但不显示 } else { previewTitle.text(titleVal.trim()).show(); } // 名称:空则隐藏,有值则显示并更新 if (nameVal.trim() === '') { previewEmail.hide().text('无名氏'); // 保留默认文案但不显示 } else { previewEmail.text(nameVal.trim()).show(); } } }, () => ({ textLength: 正文框 && 正文框.val ? String(正文框.val() || '').length : 0 })); } // 点击 No.xxxx 插入引用(保持原先光标与选择区逻辑) function 注册追记引用串号() { // 预览占位编号点击会夺走文本框焦点(浏览器在 mousedown 阶段转移焦点,click 拦不住), // 这里在 mousedown 时记录光标,click 确认是预览编号后把焦点和光标恢复到原位置 $('body').on('mousedown', 'a.h-threads-info-id', () => { const el = 正文框[0]; if (!el) return; window.__xdexPreviewFocusRestore = { el, start: el.selectionStart, end: el.selectionEnd, hadFocus: document.activeElement === el, }; }); $('body').on('click', 'a.h-threads-info-id', e => { // 如果按住 Ctrl/Meta/Shift 键,允许浏览器默认行为(在新标签页/新窗口打开链接) if (e.ctrlKey || e.metaKey || e.shiftKey) return; e.preventDefault(); // 发言预览内的占位编号不参与引用追记(保留 href="javascript:;" 以兼容站点 attr(href).replace, // 排除只按 .h-preview-box 祖先判断,不依赖 href 推断) if (e.currentTarget.closest('.h-preview-box')) { e.stopPropagation(); // 点击预览占位编号:把焦点和光标恢复到文本框原位置 const r = window.__xdexPreviewFocusRestore; delete window.__xdexPreviewFocusRestore; if (r && r.el && r.el.isConnected) { r.el.focus({ preventScroll: true }); r.el.setSelectionRange(r.start, r.end); } return; } if (!正文框.length) return; // 失焦时 selectionEnd 可能跳到文本末尾,先聚焦再读取准确选区 const el = 正文框[0]; const wasActive = document.activeElement; if (el && document.activeElement !== el) el.focus(); const start = 正文框.prop('selectionStart'); const end = 正文框.prop('selectionEnd'); const str = 正文框.val(); const left = str.substring(0, start); const right = str.substring(end); const ref = `>>${e.currentTarget.textContent.trim()}`; console.log('[引用追记]', { wasActive: wasActive?.tagName, didFocus: document.activeElement !== wasActive, start, end, strLen: str.length, hasSelection: start !== end, leftLen: left.length, rightLen: right.length, ref }); if (start !== end) { // 有选区 → 替换选区内容 console.log('[引用追记] 走替换选区分支, left长度:', left.length, 'right长度:', right.length); 正文框.val(`${left}${ref}${right}`); } else { // 无选区 → 根据光标位置插入 const branch = start === 0 ? '开头' : start === str.length ? '末尾' : '中间'; console.log('[引用追记] 走无选区分支:', branch, 'start:', start); const _newVal = (() => { if (start === 0) return `${ref}\n${right}`; if (start === str.length) return `${left}\n${ref}\n`; return `${left}\n${ref}\n${right}`; })(); 正文框.val(_newVal); } // 旧:正文框.trigger('input', ''); // jQuery .trigger('input') 不会触发原生 addEventListener('input'), // 导致 tag6 字数统计要等用户继续输入才刷新 正文框.trigger('input', ''); if (el) el.dispatchEvent(new Event('input', { bubbles: true })); 保存编辑(); // 光标定位到引用号之后——必须延迟到 click 事件彻底结束, // 否则浏览器会把焦点还给被点击的 <a> 元素 // 旧:const _cursorPos = left.length + ref.length + (start !== end ? 0 : 1); // 无选区时插入形态为(开头)`ref\n` 或(非开头)`\nref\n`,光标需落在引用号后的那个换行之后 const _cursorPos = start !== end ? left.length + ref.length : left.length + (start === 0 ? 0 : 1) + ref.length + 1; // 保存期望光标位置,供浮窗重新打开时恢复 el.__pendingQuoteCursorPos = _cursorPos; setTimeout(() => { el.focus(); el.setSelectionRange(_cursorPos, _cursorPos); // 若 focus 成功,清除 pending 标记 if (document.activeElement === el) { el.__pendingQuoteCursorPos = null; } }, 0); }); } // 粘贴图片到文件输入(保持原选择器) function 注册粘贴图片() { if (!cfg.enablePasteImage) return; window.addEventListener('paste', e => { const files = (e.clipboardData || e.originalEvent?.clipboardData)?.files || []; if (files.length) { const fileInput = document.querySelector('input[type="file"][name="image"]'); if (fileInput) { fileInput.files = files; fileInput.dispatchEvent(new Event('input', { bubbles: true })); fileInput.dispatchEvent(new Event('change', { bubbles: true })); } } }); } // 子函数:选择了图片后出现“清除图片”按钮;清除后按钮消失,恢复“选择文件” function 绑定清除图片按钮() { const $form = $('#h-post-form form, form[action="/Home/Forum/doReplyThread.html"]').first(); if (!$form.length) return; const $file = $form.find('input[type="file"][name="image"]'); if (!$file.length) return; if ($file.data('xdexClearBound')) return; $file.data('xdexClearBound', true); // 包一层容器,方便布局 if (!$file.parent().hasClass('xdex-file-wrapper')) { $file.wrap('<div class="xdex-file-wrapper" style="display:flex;align-items:center;justify-content:space-between;width:100%;"></div>'); } function 刷新按钮() { const hasFile = $file[0].files && $file[0].files.length > 0; let $btn = $form.find('.xdex-clear-image-btn'); if (hasFile) { if (!$btn.length) { $btn = $('<button type="button" class="xdex-clear-image-btn xdex-icon-btn" title="清除图片">').html(XDEX_SVG_X_ICON); $file.after($btn); $btn.on('click', function (e) { e.stopPropagation(); e.preventDefault(); // 清空文件 $file.val(''); // 直接调用预览清空逻辑(全局函数;此前误用 initPreviewBox 内的 // updatePreviewFromFile,作用域不可达导致恒走兜底分支, // 去重键因此从未重置,同一个 HEIC 重新附带时不再提示) if (typeof updatePreviewImageFromFile === 'function') { updatePreviewImageFromFile(null); } else { const $preview = $('.h-preview-box'); $preview.find('img').attr('src', '').removeAttr('src'); $preview.find('.h-threads-img-a').attr('href', ''); } // 复原预览框容器状态 const $previewBox = $('.h-preview-box .h-threads-img-box'); $previewBox.removeClass('h-active'); $previewBox.find('.h-threads-img-a').css('height', ''); $previewBox.find('.h-threads-img') .css({ transform: '', top: '0px', left: '0px' }) .removeAttr('data-rotate-index'); // 移除按钮 $(this).remove(); }); } } else { if ($btn.length) $btn.remove(); // 原生 input 被清空(选择框取消/浏览器行为)时同样要复位 HEIC 去重, // 否则下一次重新附带同一文件不会再提示 if (typeof resetHeifPreviewTipState === 'function') resetHeifPreviewTipState(); } } $file.on('change', 刷新按钮); 刷新按钮(); } // 自动标题:择标题(与原逻辑等价) function 选择标题() { return selectEnhanceIslandTitleText() || document.title; } const 原始标题 = getEnhanceIslandOriginalTitle(); function 自动标题() { if (!cfg.enableAutoTitle) return; // 每次调用时重新解析 URL const search = window.location.search; const 搜索参数 = {}; search.replace(/^\?/, '').split('&').forEach(kev => { if (!kev) return; const [k, v] = kev.split('=', 2); 搜索参数[k] = v; }); const 路径 = window.location.pathname; const 路径分块 = 路径.split('/').splice(1); const 是串页 = 路径分块[0] === 't' || (路径分块[0] === 'Forum' && 路径分块[1] === 'po' && 路径分块[2] === 'id'); if (!是串页) return; const 页码 = 路径分块[0] === 'Forum' ? (路径分块[5]?.replace(/\.html$/, '') || 1) : (搜索参数.page || 1); const 标题 = 选择标题(); const titleEl = document.querySelector('title'); if (titleEl) { titleEl.textContent = `${标题} - ${原始标题} - page ${页码}`; } } // 相对时间格式化(与原逻辑等价,目标 span.h-threads-info-createdat) function getFriendlyTime(machineReadableTime) { const date = new Date(machineReadableTime); const now = new Date(); if (now < date) return machineReadableTime; let friendlyDate = machineReadableTime.slice(0, 10); let friendlyTime = machineReadableTime.slice(13, 21); const weekday = machineReadableTime.slice(11, 12); const diff = (now.getTime() - date.getTime()) / 1000; if (diff < 60) { friendlyTime = `${Math.floor(diff)}秒前`; } else if (diff < 3600) { friendlyTime = `${Math.floor(diff / 60)}分钟前`; } else if (diff < 24 * 3600) { friendlyTime = `${Math.floor(diff / 3600)}小时前 ${friendlyTime}`; } const yesterday = new Date(new Date(now - 1000 * 60 * 60 * 24).toLocaleDateString()); if (now.toLocaleDateString() === date.toLocaleDateString()) { friendlyDate = '今天'; } else if (yesterday.toLocaleDateString() === date.toLocaleDateString()) { friendlyDate = '昨天'; } else if (yesterday - date < 1000 * 60 * 60 * 24 * 30) { friendlyDate = `${Math.floor((now - date) / (1000 * 60 * 60 * 24))}天前`; } else if (now.getFullYear() === date.getFullYear()) { friendlyDate = friendlyDate.slice(5); } else { friendlyDate = `${now.getFullYear() - date.getFullYear()}年前 ${friendlyDate}`; } return `${friendlyDate}(${weekday})${friendlyTime}`; } function formatDateStrOnPage(root = document) { if (!cfg.enableRelativeTime) return; if (document.visibilityState && document.visibilityState !== 'visible') return; if (getTimeDisplayMode() === 'exact') { restoreExactDateStrOnPage(root); return; } const $root = root && root.jquery ? root : $(root || document); const targets = withSelf($root, 'span.h-threads-info-createdat'); targets.each(function () { const target = $(this); const timeStr = target.attr('data-xdex-original-time') || target.attr('title') || target.text().trim(); if (!timeStr) return; const date = new Date(timeStr); if (Number.isNaN(date.getTime())) return; // 写前先比较:相对时间实际一分钟才变一次,无条件写 attr/text 会让每 5s 的全量遍历把整页版面弄脏, // 紧接着的 rect 读取就退化成强制同步重排(表现为每 5s 一次可感知微顿) if (target.attr('data-xdex-original-time') !== timeStr) target.attr('data-xdex-original-time', timeStr); if (target.attr('title') !== timeStr) target.attr('title', timeStr); const friendlyTime = getFriendlyTime(timeStr); if (target.text() !== friendlyTime) target.text(friendlyTime); }); } function getTimeDisplayMode() { try { const saved = GM_getValue(SettingPanel.key, {}); return saved && saved.timeDisplayMode === 'exact' ? 'exact' : 'relative'; } catch (e) { return cfg.timeDisplayMode === 'exact' ? 'exact' : 'relative'; } } function restoreExactDateStrOnPage(root = document) { if (document.visibilityState && document.visibilityState !== 'visible') return; const $root = root && root.jquery ? root : $(root || document); const targets = withSelf($root, 'span.h-threads-info-createdat'); targets.each(function () { const target = $(this); const timeStr = target.attr('data-xdex-original-time') || target.attr('title'); if (timeStr && target.text() !== timeStr) target.text(timeStr); // 同样写前比较,避免无变化的整页脏化 }); } function applyTimeDisplayMode(root = document) { if (!cfg.enableRelativeTime) return; if (getTimeDisplayMode() === 'exact') { restoreExactDateStrOnPage(root); return; } formatDateStrOnPage(root); } function stopRelativeTimeScheduler() { if (window.__xdexRelativeTimeTimer) { clearInterval(window.__xdexRelativeTimeTimer); window.__xdexRelativeTimeTimer = null; } if (window.__xdexRelativeTimeVisibilityHandler) { document.removeEventListener('visibilitychange', window.__xdexRelativeTimeVisibilityHandler); window.__xdexRelativeTimeVisibilityHandler = null; } } function ensureRelativeTimeScheduler() { if (!cfg.enableRelativeTime) { stopRelativeTimeScheduler(); return; } if (!window.__xdexRelativeTimeVisibilityHandler) { window.__xdexRelativeTimeVisibilityHandler = () => { if (document.visibilityState === 'visible') { applyTimeDisplayMode(); } }; document.addEventListener('visibilitychange', window.__xdexRelativeTimeVisibilityHandler, { passive: true }); } if (!window.__xdexRelativeTimeTimer) { window.__xdexRelativeTimeTimer = setInterval(() => { applyTimeDisplayMode(); }, 5000); } if (!document.visibilityState || document.visibilityState === 'visible') { applyTimeDisplayMode(); } } window.__xdexApplyTimeDisplayMode = applyTimeDisplayMode; // 路由:各页面初始化(与原逻辑一致) function 串() { if (cfg.enablePreview) initPreviewBox(); if (isDraftEnabled()) 载入编辑(); if (cfg.enableQuoteInsert) 注册追记引用串号(); if (cfg.enablePreview || isDraftEnabled()) 注册自动保存编辑(); if (cfg.enablePasteImage) 注册粘贴图片(); 绑定清除图片按钮(); if (cfg.enableAutoTitle) 自动标题(); ensureRelativeTimeScheduler(); } function 串只看po() { if (cfg.enableAutoTitle) 自动标题(); ensureRelativeTimeScheduler(); } function 版块() { if (cfg.enablePreview) initPreviewBox(); if (cfg.enablePreview || isDraftEnabled()) 注册自动保存编辑(); //if (cfg.enableQuoteInsert) 注册追记引用串号(); if (cfg.enablePasteImage) 注册粘贴图片(); 绑定清除图片按钮(); ensureRelativeTimeScheduler(); } function 时间线() { if (cfg.enablePreview) initPreviewBox(); if (cfg.enablePreview || isDraftEnabled()) 注册自动保存编辑(); //if (cfg.enableQuoteInsert) 注册追记引用串号(); if (cfg.enablePasteImage) 注册粘贴图片(); 绑定清除图片按钮(); ensureRelativeTimeScheduler(); } function 回复成功() { if (isDraftEnabled()) 清空编辑(); if (cfg.enablePasteImage) 注册粘贴图片(); } document.addEventListener('replySuccess', e => { 回复成功(e.detail?.key); }); function 未知() { if (cfg.enablePasteImage) 注册粘贴图片(); } // ★ 草稿槽规范化迁移(串级共享单槽;一次性,GM 标记防重) try { migrateDraftSlotsToCanonical(); } catch (e) {} // 一级路径解析(支持 m 前缀) const 一层路径 = 路径分块[0] === 'm' ? 路径分块[1] : 路径分块[0]; // 入口分流(与原脚本一致) switch (一层路径) { case 't': 串(); break; case 'f': 版块(); break; case 'Forum': if (路径分块[1] === 'po' && 路径分块[2] === 'id') { 串只看po(); } else if (路径分块[1] === 'timeline' && 路径分块[2] === 'id') { // 这里就是时间线页面 时间线(); } else { 未知(); } break; case 'Home': if (路径 === '/Home/Forum/doReplyThread.html') { 回复成功(); } else { 未知(); } break; case 'Member': if (路径.startsWith('/Member/User/Cookie/export/id/')) { console.debug('//不是我的TODO'); } break; default: 未知(); } // 首次渲染预览(若需要) if (cfg.enablePreview) { renderContent(正文框.val ? (正文框.val() || '') : ''); } // 以便无缝翻页后修改标题 window.enhanceIslandAutoTitle = 自动标题; }, () => { const textarea = document.querySelector('textarea.h-post-form-textarea'); return { path: location.pathname, textLength: textarea ? String(textarea.value || '').length : 0 }; }); } /* -------------------------------------------------- * tag 15. 板块页快速回复入口(含时间线支持) * -------------------------------------------------- */ function replyQuicklyOnBoardPage() { // 同时识别 /f/ 板块 和 /Forum/timeline/id/{id} 时间线 const isBoardPage = PageType.isBoardPage(); const timelineInfo = PageType.getTimelineInfo(); const isTimeline = !!timelineInfo; if (!isBoardPage && !isTimeline) return; // 时间线 id 与名称映射(1-7) const timelineId = timelineInfo ? timelineInfo.id : null; const timelineNameMap = { '1': '综合线', '2': '创作线', '3': '非创作线', '4': '亚文化线', '5': '综合2线', '6': '游戏线', '7': '生活线' }; // boardBaseName:仅板块/时间线的基础名称(不包含后缀),显示文本按模式拼接后缀 const boardBaseName = isTimeline ? (timelineNameMap[timelineId] || '时间线') : decodeURIComponent((location.pathname || '').replace(/^\/f\//, '').split('/')[0] || ''); // 持久变量:保存当前正在回复的串号 / 缓存的回复参数 let currentReplyTid = null; let pendingReplyParams = null; // { tid, resto, hash } // ---------- 子函数:为时间线插入回复表单(第四个子函数) ---------- function bindTimelineReplyForm() { // 找到页面上的第一个 <hr>,在其下插入我们要的回复表单容器与一个 <hr> 与线程列表分隔 const $firstHr = $('hr').first(); if (!$firstHr.length) return; const timelineLabel = timelineNameMap[timelineId] || '时间线'; const emotOptions = [ '<option value="|∀゚">|∀゚</option>', '<option value="(´゚Д゚`)">(´゚Д゚`)</option>', '<option value="(;´Д`)">(;´Д`)</option>', '<option value="(`・ω・)">(`・ω・)</option>', '<option value="(=゚ω゚)=">(=゚ω゚)=</option>', '<option value="| ω・´)">| ω・´)</option>', '<option value="|-` )">|-` )</option>', '<option value="|д` )">|д` )</option>', '<option value="|ー` )">|ー` )</option>', '<option value="|∀` )">|∀` )</option>', '<option value="(つд⊂)">(つд⊂)</option>', '<option value="(゚Д゚≡゚Д゚)">(゚Д゚≡゚Д゚)</option>', '<option value="(^o^)ノ">(^o^)ノ</option>', '<option value="(|||゚Д゚)">(|||゚Д゚)</option>', '<option value="( ゚∀゚)">( ゚∀゚)</option>', '<option value="( ´∀`)">( ´∀`)</option>', '<option value="(*´∀`)">(*´∀`)</option>', '<option value="(*゚∇゚)">(*゚∇゚)</option>', '<option value="(*゚ー゚)">(*゚ー゚)</option>', '<option value="( ゚ 3゚)">( ゚ 3゚)</option>', '<option value="( ´ー`)">( ´ー`)</option>', '<option value="( ・_ゝ・)">( ・_ゝ・)</option>', '<option value="( ´_ゝ`)">( ´_ゝ`)</option>', '<option value="(*´д`)">(*´д`)</option>', '<option value="(・ー・)">(・ー・)</option>', '<option value="(・∀・)">(・∀・)</option>', '<option value="(ゝ∀・)">(ゝ∀・)</option>', '<option value="(〃∀〃)">(〃∀〃)</option>', '<option value="(*゚∀゚*)">(*゚∀゚*)</option>', '<option value="( ゚∀。)">( ゚∀。)</option>', '<option value="( `д´)">( `д´)</option>', '<option value="(`ε´ )">(`ε´ )</option>', '<option value="(`ヮ´ )">(`ヮ´ )</option>', '<option value="σ`∀´)">σ`∀´)</option>', '<option value=" ゚∀゚)σ"> ゚∀゚)σ</option>', '<option value="゚ ∀゚)ノ">゚ ∀゚)ノ</option>', '<option value="(╬゚д゚)">(╬゚д゚)</option>', '<option value="(|||゚д゚)">(|||゚д゚)</option>', '<option value="( ゚д゚)">( ゚д゚)</option>', '<option value="Σ( ゚д゚)">Σ( ゚д゚)</option>', '<option value="( ;゚д゚)">( ;゚д゚)</option>', '<option value="( ;´д`)">( ;´д`)</option>', '<option value="( д ) ゚ ゚">( д ) ゚ ゚</option>', '<option value="( ☉д⊙)">( ☉д⊙)</option>', '<option value="((( ゚д゚)))">((( ゚д゚)))</option>', '<option value="( ` ・´)">( ` ・´)</option>', '<option value="( ´д`)">( ´д`)</option>', '<option value="( -д-)">( -д-)</option>', '<option value="(>д<)">(>д<)</option>', '<option value="・゚( ノд`゚)">・゚( ノд`゚)</option>', '<option value="( TдT)">( TдT)</option>', '<option value="( ̄∇ ̄)">( ̄∇ ̄)</option>', '<option value="( ̄3 ̄)">( ̄3 ̄)</option>', '<option value="( ̄ー ̄)">( ̄ー ̄)</option>', '<option value="( ̄ .  ̄)">( ̄ .  ̄)</option>', '<option value="( ̄皿 ̄)">( ̄皿 ̄)</option>', '<option value="( ̄艸 ̄)">( ̄艸 ̄)</option>', '<option value="( ̄︿ ̄)">( ̄︿ ̄)</option>', '<option value="( ̄︶ ̄)">( ̄︶ ̄)</option>', '<option value="ヾ(´ω゚`)">ヾ(´ω゚`)</option>', '<option value="(*´ω`*)">(*´ω`*)</option>', '<option value="(・ω・)">(・ω・)</option>', '<option value="( ´・ω)">( ´・ω)</option>', '<option value="(`・ω)">(`・ω)</option>', '<option value="(´・ω・`)">(´・ω・`)</option>', '<option value="(`・ω・´)">(`・ω・´)</option>', '<option value="( `_っ´)">( `_っ´)</option>', '<option value="( `ー´)">( `ー´)</option>', '<option value="( ´_っ`)">( ´_っ`)</option>', '<option value="( ´ρ`)">( ´ρ`)</option>', '<option value="( ゚ω゚)">( ゚ω゚)</option>', '<option value="(o゚ω゚o)">(o゚ω゚o)</option>', '<option value="( ^ω^)">( ^ω^)</option>', '<option value="(。◕∀◕。)">(。◕∀◕。)</option>', '<option value="/( ◕‿‿◕ )\\">/( ◕‿‿◕ )\\</option>', '<option value="ヾ(´ε`ヾ)">ヾ(´ε`ヾ)</option>', '<option value="(ノ゚∀゚)ノ">(ノ゚∀゚)ノ</option>', '<option value="(σ゚д゚)σ">(σ゚д゚)σ</option>', '<option value="(σ゚∀゚)σ">(σ゚∀゚)σ</option>', '<option value="|д゚ )">|д゚ )</option>', '<option value="┃電柱┃">┃電柱┃</option>', '<option value="゚(つд`゚)">゚(つд`゚)</option>', '<option value="゚Å゚ ) ">゚Å゚ ) </option>', '<option value="⊂彡☆))д`)">⊂彡☆))д`)</option>', '<option value="⊂彡☆))д´)">⊂彡☆))д´)</option>', '<option value="⊂彡☆))∀`)">⊂彡☆))∀`)</option>', '<option value="(´∀((☆ミつ">(´∀((☆ミつ</option>', '<option value="・゚( ノヮ´ )">・゚( ノヮ´ )</option>', '<option value="(ノ)`ω´(ヾ)">(ノ)`ω´(ヾ)</option>', '<option value="ᕕ( ᐛ )ᕗ">ᕕ( ᐛ )ᕗ</option>', '<option value="( ˇωˇ)">( ˇωˇ)</option>', '<option value="( 」゚Д゚)」<">( 」゚Д゚)」<</option>', '<option value="( ›´ω`‹ )">( ›´ω`‹ )</option>', '<option value="(;´ヮ`)7">(;´ヮ`)7</option>', '<option value="(`ゥ´ )">(`ゥ´ )</option>', '<option value="(`ᝫ´ )">(`ᝫ´ )</option>', '<option value="( ᑭ`д´)ᓀ))д´)ᑫ">( ᑭ`д´)ᓀ))д´)ᑫ</option>', '<option value="σ( ᑒ )">σ( ᑒ )</option>', '<option value="(`ヮ´ )σ`∀´) ゚∀゚)σ">齐齐蛤尔</option>', '<option value="吁~~~~ rnm,退钱!<br> / / <br>( ゚ 3゚) `ー´) `д´) `д´)">大嘘</option>', '<option value="[h] [/h]">防剧透</option>', '<option value="[n]">骰子</option>', '<option value="[n,m]">高级骰子</option>' ].join('\n'); const formHtml = ` <div id="h-post-form" class="uk-container-center uk-width-small-8-10 uk-width-medium-4-10 uk-width-large-4-10"> <form action="/Home/Forum/doReplyThread.html" method="post" enctype="multipart/form-data"> <!-- 隐藏字段,默认用 与 原来 setMode 一致的占位值(会在点击链接时被替换) --> <input type="hidden" name="resto" value="20011114"> <input type="hidden" name="__hash__" value="cirns"> <!-- 回应模式行(包含兼容原来逻辑的类和占位)--> <div class="uk-grid uk-grid-small h-post-form-grid js-reply-mode-row"> <div class="uk-width-1-5"> <div class="h-post-form-title">回应模式</div> </div> <div class="h-post-form-input uk-width-4-5" style="display:flex;align-items:center;justify-content:space-between;"> <span class="js-reply-mode-text">${timelineLabel}-快速回复</span> <span class="xdex-cookie-check-area" style="display:flex;align-items:center;gap:4px;flex-shrink:0;"></span> <div class="reply-mode-toggle" style="display:flex;flex-direction:row;align-items:center;gap:6px;"> <span class="js-reply-extra" style="display:none;display:inline-flex;align-items:center;"></span> <button type="button" class="js-toggle-mode xdex-reply-mode-btn" title="当前为"回复"模式,点击切换为"发串"模式">${XDEX_SVG_SWITCH}</button> </div> </div> </div> <!-- 以下为你提供的表单其余内容(保留原样,确保 textarea, input 名称等一致) --> <div class="uk-grid uk-grid-small h-post-form-grid"> <div class="uk-width-1-5"> <div class="h-post-form-title">名 称</div> </div> <div class="h-post-form-input uk-width-3-5"> <input type="text" name="name" size="28" value="" maxlength="100"> </div> <div class="h-post-form-option uk-width-1-5"> <label class="h-admin-tool"><input type="checkbox" name="isManager" value="true">管理员</label> </div> </div> <div class="uk-grid uk-grid-small h-post-form-grid"> <div class="uk-width-1-5"> <div class="h-post-form-title">E-mail</div> </div> <div class="h-post-form-input uk-width-3-5"> <input type="text" name="email" size="28" value="" maxlength="100"> </div> </div> <div class="uk-grid uk-grid-small h-post-form-grid"> <div class="uk-width-1-5"> <div class="h-post-form-title">标题</div> </div> <div class="h-post-form-input uk-width-3-5"> <input type="text" name="title" size="28" value="" maxlength="100"> </div> <div class="h-post-form-option uk-width-1-5"> <input type="submit" value="送出"> </div> </div> <!-- 颜文字、正文、附图等 --> <div class="uk-grid uk-grid-small h-post-form-grid"> <div class="uk-width-1-5"> <div class="h-post-form-title">颜文字</div> </div> <div class="h-post-form-input uk-width-1-5"> <select id="h-emot-select"> ${emotOptions} </select> </div> </div> <div class="uk-grid uk-grid-small h-post-form-grid"> <div class="uk-width-1-5"> <div class="h-post-form-title h-post-form-textarea-title">正文</div> </div> <div class="h-post-form-input uk-width-4-5"> <textarea name="content" maxlength="10000" class="h-post-form-textarea"></textarea> </div> </div> <div class="uk-grid uk-grid-small h-post-form-grid"> <div class="uk-width-1-5"> <div class="h-post-form-title">附加图片</div> </div> <div class="h-post-form-option uk-width-1-6"> <label class="h-water-tool"> <input type="checkbox" name="water" value="true" checked="true">水印 </label> </div> <div class="h-post-form-input uk-width-3-5"> <input type="file" name="image"> </div> </div> <!-- 如果页面需要 __hash__ 的实际值,可以在点击串链接并 fetch 后替换此隐藏值 --> <!-- 注意:原来 HTML 示例中的 long-hash 在这里用占位 'cirns',后续会覆盖 --> <input type="hidden" name="__hash__" value="cirns"> </form> <div class="uk-clearfix"></div> </div> `; // 插入表单:在第一个 hr 下插入表单和一个新的 hr(把表单与列表分离) $firstHr.after(formHtml + '<hr>'); // 返回插入的 form jQuery 对象(方便调用端定位) return $('#h-post-form form').first(); document.dispatchEvent(new Event('timelineReplyFormInserted')); } // ---------- 根据页面场景获取或插入表单 ---------- let $formPost = $('form[action="/Home/Forum/doPostThread.html"]').first(); // 板块页发串表单 let $formReply = $('form[action="/Home/Forum/doReplyThread.html"]').first(); // 串内页回串表单 // TODO 值班室板块回复表单需要选择原因,无论在板块页快速回复还是串内回复 if ($formPost.length) { // 当前是板块页,直接使用发串表单 } else if ($formReply.length) { // 当前是串内页,直接使用回串表单 } else if (PageType.isTimelineLikePage()) { // 当前是时间线页,无表单 → 插入一个回串表单 $formReply = $(` <form action="/Home/Forum/doReplyThread.html" method="post" id="timeline-reply-form"> <!-- 这里填入串内页回复表单的必要字段 --> </form> `); $('body').append($formReply); } if (!$formPost.length && isTimeline) { $formPost = bindTimelineReplyForm(); } // 时间线:插入/刷新完表单后,纳入统一的“包裹 + 折叠” if (isTimeline && $formPost && $formPost.length) { ensureCollapsed($formPost, '『回复』'); } if (!$formPost || !$formPost.length) return; // 如果表单中已经存在“回应模式”行(例如我们插入的时间线表单),则复用;否则按原逻辑插入 $row let $row = null; if ($formPost.find('.h-post-form-title:contains("回应模式")').length) { // 优先找到带 js-reply-mode-row 的行(如果插入时已包含) $row = $formPost.find('.js-reply-mode-row').first(); } else { // 原有逻辑:找到 名称 行 作为插入位置 const $nameRow = $formPost.find('.h-post-form-title').filter(function(){ return $(this).text().trim() === '名 称'; }).closest('.h-post-form-grid').first(); if (!$nameRow.length) return; // 插入回应模式行(与原代码结构一致) $row = $(` <div class="uk-grid uk-grid-small h-post-form-grid js-reply-mode-row"> <div class="uk-width-1-5"> <div class="h-post-form-title">回应模式</div> </div> <div class="h-post-form-input uk-width-4-5" style="display:flex;align-items:center;justify-content:space-between;"> <span class="js-reply-mode-text">板块名或串号</span> <span class="xdex-cookie-check-area" style="display:flex;align-items:center;gap:4px;flex-shrink:0;"></span> <div class="reply-mode-toggle" style="display:flex;flex-direction:row;align-items:center;gap:6px;"> <span class="js-reply-extra" style="display:none;display:inline-flex;align-items:center;"></span> <button type="button" class="js-toggle-mode xdex-reply-mode-btn" title="当前为"回复"模式,点击切换为"发串"模式">${XDEX_SVG_SWITCH}</button> </div> </div> </div> `); // 插入到 placeholder 或 名称行之前 let $insertBefore = $formPost.find('.xdex-placeholder').first(); if ($insertBefore.length) { $insertBefore.before($row); } else { $nameRow.before($row); } } // 如果当前页面是时间线:不允许发串,隐藏切换按钮并强制设为 回复 模式(但保留临时/连续按钮) if (isTimeline) { $row.find('.js-toggle-mode').hide(); } // 状态与事件(复用原有逻辑) window.replyModeState = { mode: '发串', extra: null }; function emitReplyModeChange() { document.dispatchEvent(new CustomEvent('replyModeChange', { detail: window.replyModeState })); } // 切换逻辑(保留原版 setMode,外部依赖不变) const $modeBtns = $row.find('.js-mode'); // 兼容原代码(若不存在不会报错) function setMode(mode, {silent = false} = {}) { // 保留用户手动选择的 临时/连续:发串 分支会 .empty() 掉整排按钮并把 extra 置 null, // 切回 回复 时按钮 DOM 与 window.replyModeState 都已无值,只能靠 $row 上持久化的值还原, // 否则每次 发串↔回复 往返都会把用户的选择打回默认(首帧无历史值 → 仍走默认) const prevExtra = $row.find('.js-extra').attr('data-extra') || $row.data('xdexLastExtra') || (window.replyModeState && window.replyModeState.extra) || null; $modeBtns.removeClass('active').filter('[data-mode="'+mode+'"]').addClass('active'); if (mode === '发串') { $formPost.attr('action', '/Home/Forum/doPostThread.html'); $row.find('.js-reply-extra').hide().empty(); $row.find('.js-reply-mode-text').text(boardBaseName ? (boardBaseName + '-发串') : '板块-发串'); window.replyModeState = { mode: '发串', extra: null }; // 发串模式不需要饼干偏好 clearCookieCheckSwitch(); if (!silent) { toast('已切换到 发串 模式'); } } else if (mode === '回复') { $formPost.attr('action', '/Home/Forum/doReplyThread.html'); // 确保 hidden 存在 let $resto = $formPost.find('input[name="resto"]'); if (!$resto.length) { $resto = $('<input type="hidden" name="resto">').appendTo($formPost); } let $hash = $formPost.find('input[name="__hash__"]'); if (!$hash.length) { $hash = $('<input type="hidden" name="__hash__">'); $resto.after($hash); // 保证顺序 } let autofilled = false; if (pendingReplyParams) { // 自动填充缓存的参数 $resto.val(pendingReplyParams.resto); $hash.val(pendingReplyParams.hash); $row.find('.js-reply-mode-text').html( `<font color="#789922">No.${pendingReplyParams.tid}</font>` ); const $replyModeText = $row.find('.js-reply-mode-text'); // 触发扩展引用 const root = $replyModeText[0]; if (typeof initExtendedContent === 'function') { try { initExtendedContent(root); } catch(e){} } if (typeof initContent === 'function') { try { initContent(root); } catch(e){} } //if (typeof autoHideRefView === 'function') { try { autoHideRefView(root); } catch(e){} } toast('已自动填充回复串号,请确认无误后再发送'); // 串号变化 → 先解析偏好再重建开关,避免开关渲染与偏好脱节 if (typeof initThreadCookiePref === 'function') initThreadCookiePref(pendingReplyParams.tid); if (typeof injectCookieCheckSwitch === 'function') injectCookieCheckSwitch(pendingReplyParams.tid); pendingReplyParams = null; // 用过一次就清空 autofilled = true; } else { // 默认逻辑 $resto.val('20011114'); $hash.val('cirns'); // 对于时间线,默认显示的文本已经在插入表单时写成 `${timeline}-快速回复`,这里保持不变 if (!isTimeline) { $row.find('.js-reply-mode-text').text((boardBaseName || '板块') + '-快速回复'); } // 无串上下文(切回回复模式但未指定串):开关同样不得残留上一串状态 clearCookieCheckSwitch(); } // 插入“临时/连续”按钮(若尚未插入) const $extra = $row.find('.reply-mode-toggle .js-reply-extra'); if (!$extra.children().length) { // 包裹容器 const $wrapper = $('<div class="xdex-file-wrapper" style="display:flex;align-items:center;justify-content:space-between;width:100%;"></div>'); // “×”按钮 const $btnReset = $('<button type="button" class="js-reset xdex-reply-mode-btn" title="重置回复目标" style="margin-right:6px;">').html(XDEX_SVG_X_ICON); $btnReset.on('click', function(){ // 重置 hidden 值 $formPost.find('input[name="resto"]').val('20011114'); $formPost.find('input[name="__hash__"]').val('cirns'); // 重置显示文本 if (isTimeline) { const timelineNames = {1:'综合线',2:'创作线',3:'非创作线',4:'亚文化线',5:'综合2线',6:'游戏线',7:'生活线'}; const label = timelineNames[timelineId] || '时间线'; $row.find('.js-reply-mode-text').text(label + '-快速回复'); } else { $row.find('.js-reply-mode-text').text((boardBaseName || '板块') + '-快速回复'); } // 重置饼干偏好开关 clearCookieCheckSwitch(); // **清空正文 textarea** $formPost.find('textarea.h-post-form-textarea').val(''); // **清空标题、名称、Email** $formPost.find('input[name="title"]').val(''); $formPost.find('input[name="name"]').val(''); $formPost.find('input[name="email"]').val(''); // 重置预览框 const previewBox = document.querySelector('.h-preview-box'); if (previewBox) { // 获取当前饼干 const cur = getCurrentCookie(); const cookieText = cur ? cur.name : '--'; previewBox.innerHTML = buildEnhanceIslandPreviewHtml(false); if (typeof enableHDImage === 'function') { enableHDImage(previewBox); } } updatePreviewCookieId(); toast('已重置'); }); // “临时/连续”按钮 const $btnExtra = $('<button type="button" class="js-extra xdex-reply-mode-btn" data-extra="临时" title=\'当前为"临时"回复模式,点击切换为"连续"回复模式\'>').html(XDEX_SVG_STOPWATCH); $btnExtra.on('click', function(e){ // 下面会用 .html() 替换被点击的图标本身:若不阻断冒泡,遮罩处理器拿到的 e.target // 已脱离文档,closest('.qp-quote') 判定失效,会把这次点击误判为「点了遮罩」而关闭浮窗 e.stopPropagation(); const cur = $(this).attr('data-extra'); if (cur === '临时') { $(this).attr('data-extra','连续').html(XDEX_SVG_INFINITY).attr('title','当前为"连续"回复模式,点击切换为"临时"回复模式'); window.replyModeState = { mode: '回复', extra: '连续' }; $row.data('xdexLastExtra', '连续'); // 持久化用户选择,供 发串→回复 往返后还原 toast('已切换到 连续 回复模式'); } else { $(this).attr('data-extra','临时').html(XDEX_SVG_STOPWATCH).attr('title','当前为"临时"回复模式,点击切换为"连续"回复模式'); window.replyModeState = { mode: '回复', extra: '临时' }; $row.data('xdexLastExtra', '临时'); // 持久化用户选择,供 发串→回复 往返后还原 toast('已切换到 临时 回复模式'); } emitReplyModeChange(); }); // 组装 $wrapper.append($btnReset).append($btnExtra); $extra.append($wrapper); } $extra.show(); // 新建的按钮固定是 临时 图标,这里按 prevExtra 还原(无历史值时保持 临时,再由下方初始状态块应用设置项默认) if (prevExtra === '连续') { $row.find('.js-extra').attr('data-extra','连续').html(XDEX_SVG_INFINITY).attr('title','当前为"连续"回复模式,点击切换为"临时"回复模式'); } window.replyModeState = { mode: '回复', extra: prevExtra === '连续' ? '连续' : '临时' }; // 只有在没有自动填充时,才显示默认切换提示 if (!autofilled && !silent) { toast('已切换到 回复 模式'); } } // 同步“切换”按钮悬浮提示(setMode 所有路径统一出口) { const modeName = window.replyModeState.mode === '发串' ? '发串' : '回复'; const nextName = modeName === '发串' ? '回复' : '发串'; $row.find('.js-toggle-mode').attr('title', '当前为"' + modeName + '"模式,点击切换为"' + nextName + '"模式'); } emitReplyModeChange(); } // 绑定模式按钮(原先存在的行为) $modeBtns.on('click', function(){ setMode($(this).attr('data-mode')); }); // 记录本行按钮上已有的 临时/连续('临时' 表示用户手动选过;本函数可重复执行,如无缝加载后重跑) // 下方三处初始状态分支据此避免用设置项默认值覆盖用户的选择 const rowPrevExtra = $row.find('.js-extra').attr('data-extra') || $row.data('xdexLastExtra') || null; // 初始状态:如果是时间线则强制 回复 模式(silent),否则默认静默发串 if (isTimeline) { setMode('回复', {silent: true}); // extra 模式(rowPrevExtra 为 '临时' 说明用户手动选过,不再用设置项默认覆盖) if (SettingPanel.state.replyExtraDefault === '连续' && rowPrevExtra !== '临时') { // 模拟点击一次“临时/连续”按钮,或者直接设置 window.replyModeState.extra = '连续'; $row.find('.js-extra').attr('data-extra','连续').html(XDEX_SVG_INFINITY).attr('title','当前为"连续"回复模式,点击切换为"临时"回复模式'); } } else if (PageType.isSpecialBoard()) { // 值班室/测试版块强制发串模式,方便快速举报/测试(不修改设置项,用户仍可手动切换回复模式) const _boardName = (/^\/f\/(值班室|测试)(?:\/|$)/.exec(decodeURIComponent(location.pathname)) || [])[1] || '特殊'; setMode('发串', {silent: true}); try { const today = new Date().toLocaleDateString('sv-SE'); const toastKey = 'xdex_' + _boardName + '_post_mode_toast_date'; const lastShown = typeof GM_getValue === 'function' ? GM_getValue(toastKey, '') : ''; if (lastShown !== today) { toast(_boardName + '版块默认为"发串"模式,请注意', 0, { type: 'error', key: 'board-post-mode-notice' }); if (typeof GM_setValue === 'function') GM_setValue(toastKey, today); } } catch (e) { toast(_boardName + '版块默认为"发串"模式,请注意', 0, { type: 'error', key: 'board-post-mode-notice' }); } if (SettingPanel.state.replyExtraDefault === '连续' && rowPrevExtra !== '临时') { window.replyModeState.extra = '连续'; $row.find('.js-extra').attr('data-extra','连续').html(XDEX_SVG_INFINITY).attr('title','当前为"连续"回复模式,点击切换为"临时"回复模式'); } } else { setMode(SettingPanel.state.replyModeDefault, {silent: true}); // extra 模式(rowPrevExtra 为 '临时' 说明用户手动选过,不再用设置项默认覆盖) if (SettingPanel.state.replyExtraDefault === '连续' && rowPrevExtra !== '临时') { window.replyModeState.extra = '连续'; $row.find('.js-extra').attr('data-extra','连续').html(XDEX_SVG_INFINITY).attr('title','当前为"连续"回复模式,点击切换为"临时"回复模式'); } } // 切换按钮逻辑(若存在切换按钮) $row.find('.js-toggle-mode').on('click', function(){ if (window.replyModeState.mode === '发串') { setMode('回复'); } else { setMode('发串'); } }); // 监听发送成功信号(兼容原逻辑) document.addEventListener('replySuccess', e => { if (window.replyModeState?.mode !== '回复') return; const $form = $('form[action="/Home/Forum/doReplyThread.html"]').first(); if (window.replyModeState.extra === '临时') { // 重置为默认 $form.find('input[name="resto"]').val('20011114'); $form.find('input[name="__hash__"]').val('cirns'); const displayName = isTimeline ? (timelineNameMap[timelineId] || '时间线') : (boardBaseName || '板块'); $('.js-reply-mode-row .js-reply-mode-text').text(`${displayName}-快速回复`); // 串上下文已清除,饼干偏好开关必须一并清空:否则残留上一串的默认饼干状态与齿轮 clearCookieCheckSwitch(); // 广播“临时回复发送成功” document.dispatchEvent(new CustomEvent('tempReplySuccess', { detail: { key: e.detail?.key, tid: e.detail?.tid } })); } else if (window.replyModeState.extra === '连续') { // 不清理,广播“连续回复发送成功” document.dispatchEvent(new CustomEvent('contReplySuccess', { detail: { key: e.detail?.key, tid: e.detail?.tid } })); } }); // ---------- 子函数2:处理带 r= 的串链接点击(复用并兼容时间线插入的表单) ---------- function bindReplyQuoteLinks() { $('body').on('click', 'a[href*="/t/"][href*="r="]', function(e) { if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; const url = new URL(this.href, location.origin); const tid = url.pathname.split('/')[2]; const rid = url.searchParams.get('r'); currentReplyTid = tid; if (!tid || !rid) return; e.preventDefault(); const $textarea = $('textarea.h-post-form-textarea'); if (!$textarea.length) return; if (window.replyModeState?.mode === '发串') { insertQuote($textarea, rid); // 抓取参数并缓存(用于后续切换到 回复 模式 自动填充) $.get(`/t/${tid}?r=${rid}`).done(html => { const $doc = $(html); const restoVal = $doc.find('form[action="/Home/Forum/doReplyThread.html"] input[name="resto"]').val(); const hashVal = $doc.find('form[action="/Home/Forum/doReplyThread.html"] input[name="__hash__"]').val(); if (restoVal && hashVal) { pendingReplyParams = { tid, resto: restoVal, hash: hashVal }; } }); } else { $.get(`/t/${tid}?r=${rid}`).done(html => { const $doc = $(html); const restoVal = $doc.find('form[action="/Home/Forum/doReplyThread.html"] input[name="resto"]').val(); const hashVal = $doc.find('form[action="/Home/Forum/doReplyThread.html"] input[name="__hash__"]').val(); if (restoVal && hashVal) { const $form = $('form[action="/Home/Forum/doReplyThread.html"]').first(); $form.find('input[name="resto"]').val(restoVal); $form.find('input[name="__hash__"]').val(hashVal); // 在回应模式行中显示串号(直接插入 font) const $replyModeText = $('.js-reply-mode-row .js-reply-mode-text'); $replyModeText.html( `<font color="#789922" data-darkreader-inline-color="" style="--darkreader-inline-color: var(--darkreader-text-789922, #aec66f);">No.${tid}</font>` ); // 串号变化 → 先解析偏好再重建开关,避免开关渲染与偏好脱节 if (typeof initThreadCookiePref === 'function') initThreadCookiePref(tid); if (typeof injectCookieCheckSwitch === 'function') injectCookieCheckSwitch(tid); const root = $replyModeText[0]; if (typeof initExtendedContent === 'function') { try { initExtendedContent(root); } catch(e){} } if (typeof initContent === 'function') { try { initContent(root); } catch(e){} } //if (typeof autoHideRefView === 'function') { try { autoHideRefView(root); } catch(e){} } } if (tid !== rid) { insertQuote($textarea, rid); } }); } }); function insertQuote($textarea, rid) { // 手动追记:放行关闭引用拦截,避免 >>No.{r} 被剥掉 if (typeof allowManualQuoteInsert === 'function') allowManualQuoteInsert(rid); const start = $textarea.prop('selectionStart'); const end = $textarea.prop('selectionEnd'); const str = $textarea.val(); const left = str.substring(0, start); const right = str.substring(end); const ref = `>>No.${rid}`; let newVal; if (start === 0) { newVal = `${ref}\n${right}`; } else if (end === str.length) { newVal = `${left}\n${ref}\n`; } else if (end > start) { newVal = `${left} ${ref} ${right}`; } else { newVal = `${left}\n${ref}${right}`; } // 旧:$textarea.val(newVal).trigger('input'); // 同步派发原生 input,保证字数统计即时刷新 $textarea.val(newVal).trigger('input'); const taEl = $textarea[0]; if (taEl) taEl.dispatchEvent(new Event('input', { bubbles: true })); } } // ---------- 公用:重新应用页面增强(保持原样) ---------- // function reapplyPageEnhancements(root = document) { // if (typeof hideEmptyTitleAndEmail === 'function') {try { hideEmptyTitleAndEmail(root); } catch (e) {}} // //if (typeof updateReplyNumbers === 'function') {try { updateReplyNumbers(root); } catch (e) {}} // if (typeof highlightPO === 'function') {try { highlightPO(root); } catch (e) {}} // if (typeof enableHDImageAndLayoutFix === 'function') {try { enableHDImageAndLayoutFix(root); } catch (e) {}} // if (typeof extendQuote === 'function') {try { extendQuote(root); } catch (e) {}} // try { if (typeof applyFilters === 'function') applyFilters(cfg, root); } catch (e) { try { if (typeof applyFilters === 'function') applyFilters(cfg); } catch (e) {} } // if (typeof initExtendedContent === 'function') {try { initExtendedContent(root); } catch (e) {}} // if (typeof initContent === 'function') {try { initContent(root); } catch (e) {}} // if (typeof autoHideRefView === 'function') {try { autoHideRefView(root); } catch (e) {}} // if (typeof enablePostExpand === 'function') {try { aenablePostExpand(); } catch (e) {}} // } // ---------- 子函数3:板块/时间线快速回复刷新逻辑(已扩展以支持时间线) ---------- // done 将板块页快速回复-局部刷新修改为增量模式 function bindBoardQuickReplyRefresh() { document.addEventListener('tempReplySuccess', handleBoardQuickReplyRefresh); document.addEventListener('contReplySuccess', handleBoardQuickReplyRefresh); document.addEventListener('xdexBoardThreadRefresh', handleBoardQuickReplyRefresh); // 按钮触发的刷新:同串并发去重(回复后的自动增量不受影响) const _boardRefreshInflight = new Set(); // === v3: API 拉取末页+倒数第二页,位置感知增量合并,滚动位置保持 === function handleBoardQuickReplyRefresh(e) { // 只在 板块页 或 时间线 页生效 if (!PageType.isBoardPage() && !PageType.isTimelinePage()) return; const btn = (e.detail && e.detail.btn) || null; const fromButton = !!(e.detail && e.detail.fromButton); const tid = e.detail?.tid || currentReplyTid; if (!tid) { toast('订阅失败:未识别到当前串号'); return; } if (fromButton) { if (_boardRefreshInflight.has(String(tid))) return; _boardRefreshInflight.add(String(tid)); if (btn) { btn.disabled = true; btn.classList.add('xdex-icon-loading'); } } const cfg2 = (typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state) ? SettingPanel.state : null; const REPLY_PER_PAGE = 19; const API_BASE = 'https://api.nmb.best/api'; // ★ 整链计时:板块页/时间线发送后增量刷新(B 场景)。 // 注意用 begin/finish 而非 measure:本函数是 async,measure 只能量到首个 await 之前, // 真正的同步 DOM 工作(Step 5-8)在多个 await 之后的续体里。 const __perfToken = startupPerfDebug.begin(); (async () => { try { // Step 1: 拉取第1页获取 ReplyCount → 计算末页页码 // ★ 逐段计时:整链 7.5s 而同步 DOM 仅 ~28ms,必须确认耗时是网络还是「桥接阻塞」 const __tA1 = startupPerfDebug.begin(); const p1Resp = await gmRequest(API_BASE + '/thread?id=' + encodeURIComponent(tid) + '&page=1', 'json'); startupPerfDebug.finish('boardQuickReply.await.p1', __tA1, () => ({})); const p1Raw = p1Resp && (p1Resp.response || p1Resp.responseText); const p1 = typeof p1Raw === 'string' ? JSON.parse(p1Raw) : p1Raw; if (!p1 || p1.success === false) throw new Error((p1 && p1.error) || 'API error'); const replyCount = Number(p1.ReplyCount || p1.reply_count || 0); // OP 主的饼干哈希: 用于增量插入回复时补插 (PO主) 标记 const opUserHash = String((p1 && (p1.user_hash || p1.UserHash)) || ''); const tailPage = Math.max(1, Math.ceil(replyCount / REPLY_PER_PAGE)); // Step 2: 拉取末页 const __tA2 = startupPerfDebug.begin(); const tResp = await gmRequest(API_BASE + '/thread?id=' + encodeURIComponent(tid) + '&page=' + tailPage, 'json'); startupPerfDebug.finish('boardQuickReply.await.tail', __tA2, () => ({ page: tailPage })); const tRaw = tResp && (tResp.response || tResp.responseText); const t = typeof tRaw === 'string' ? JSON.parse(tRaw) : tRaw; if (!t || t.success === false) throw new Error((t && t.error) || 'API error'); let tailReplies = (Array.isArray(t.Replies) ? t.Replies : []).filter(r => r && Number(r.id) !== 9999999); // ★ 借道回填:本次请求的数据已含刚发回复与 thread 顶层 fid——立即补齐 // pending 发言历史记录的 fid/forumName/page/title(零新增请求,字段即刻安全) try { enrichPendingPostHistoryFromThreadData(t, tailReplies, tailPage); } catch (e) {} // Step 3: 定位页面上**所有**该串节点(无缝翻页后同一串可能出现在多个 .h-threads-list 中) const allOldNodes = Array.from(document.querySelectorAll('.h-threads-list div[data-threads-id="' + tid + '"]')); if (!allOldNodes.length) return; // 用第一个节点的最后一条回复 ID 作为 API 数据范围的基准 const firstNode = allOldNodes[0]; const firstReplyEls = Array.from(firstNode.querySelectorAll('.h-threads-item-reply[data-threads-id]')); const lastOldId = firstReplyEls.length ? Number(firstReplyEls[firstReplyEls.length - 1].getAttribute('data-threads-id')) : 0; // Step 4: 定位 lastOldId 并确定新回复范围 // 关键:板块页展示的最近5条回复可能跨页(如 [18,19] 在倒数第二页,[1,2,3] 在末页) // 如果 lastOldId 不在末页,必须同时拉取倒数第二页,避免漏掉中间的回复 let newReplies = []; if (lastOldId > 0) { const idx = tailReplies.findIndex(r => r && Number(r.id) === lastOldId); if (idx >= 0) { // lastOldId 在末页中 → 取其后的所有回复 newReplies = tailReplies.slice(idx + 1); } else { // lastOldId 不在末页 → 需要拉倒数第二页填补间隙 if (tailPage > 1) { const __tA3 = startupPerfDebug.begin(); const pResp = await gmRequest(API_BASE + '/thread?id=' + encodeURIComponent(tid) + '&page=' + (tailPage - 1), 'json'); startupPerfDebug.finish('boardQuickReply.await.prev', __tA3, () => ({ page: tailPage - 1 })); const pRaw = pResp && (pResp.response || pResp.responseText); const p = typeof pRaw === 'string' ? JSON.parse(pRaw) : pRaw; const prevReplies = (Array.isArray(p && p.Replies) ? p.Replies : []).filter(r => r && Number(r.id) !== 9999999); // 合并两页:倒数第二页 + 末页 const merged = prevReplies.concat(tailReplies); const mIdx = merged.findIndex(r => r && Number(r.id) === lastOldId); if (mIdx >= 0) { newReplies = merged.slice(mIdx + 1); } else { // 两页数据里都找不到页面上的最后一条回复 → 用户看到的页面数据太老 toast('当前页面数据过旧,请手动刷新页面'); return; } } else { // 只有一页且找不到 lastOldId → 页面数据过旧 if (tailReplies.some(r => r && Number(r.id) === lastOldId)) { const sIdx = tailReplies.findIndex(r => r && Number(r.id) === lastOldId); newReplies = tailReplies.slice(sIdx + 1); } else { toast('当前页面数据过旧,请手动刷新页面'); return; } } } } else { // 无回复区串:页面从未展示过回复;排除 PO 主帖(Replies[0].id === tid)后即为新增回复 newReplies = tailReplies.filter(r => r && Number(r.id) !== Number(tid)); } if (!newReplies.length) { if (fromButton) toast('已是最新回复'); return; } // Step 5: 保存滚动位置(插入前) // ★ 强制同步布局热点:每个 node 读一次 getBoundingClientRect + offsetHeight const __t5 = startupPerfDebug.begin(); const scrollEl = document.scrollingElement || document.documentElement; const scrollTopBefore = scrollEl.scrollTop; const viewportTop = scrollTopBefore; // 记录每个节点插入前的 offsetTop 和高度(用于滚动补偿) const nodeMetricsBefore = allOldNodes.map(node => { const rect = node.getBoundingClientRect(); return { node, bottom: rect.bottom, height: node.offsetHeight }; }); startupPerfDebug.finish('boardQuickReply.step5.measure', __t5, () => ({ nodes: allOldNodes.length })); // Step 6: 遍历所有匹配节点,每个节点独立增量追加 // ★ 每次 appendChild 都会触发 document.body 子树的多个观察器(hdGlobal 等), // 插入 N 条 = N 轮全页观察器回调,是「发送后卡顿」的首要嫌疑 const __t6 = startupPerfDebug.begin(); let __inserted = 0; const appendedNodes = []; for (const node of allOldNodes) { let rc = node.querySelector('.h-threads-item-replies'); // 无回复区(新串未展开过回复)→ 创建容器,否则增量刷新会被静默跳过 if (!rc) { rc = document.createElement('div'); rc.className = 'h-threads-item-replies'; node.appendChild(rc); } // 收集该节点已有的回复 ID const existingIds = new Set( Array.from(rc.querySelectorAll('.h-threads-item-reply[data-threads-id]')) .map(el => el.getAttribute('data-threads-id')) .filter(id => id && id !== '9999999') ); // 只追加该节点缺失的回复 for (const reply of newReplies) { if (!existingIds.has(String(reply.id))) { const el = buildApiReplyNode(reply, tid, opUserHash); if (el) { rc.appendChild(el); appendedNodes.push(el); __inserted++; } } } } startupPerfDebug.finish('boardQuickReply.step6.insert', __t6, () => ({ nodes: allOldNodes.length, inserted: __inserted })); // Step 7: 计算视口上方节点增长的高度,补偿滚动位置 // ★ 又一次强制同步布局:读取每个 node 的 offsetHeight + 写 scrollTop const __t7 = startupPerfDebug.begin(); // 只有完全在视口上方(bottom <= viewportTop)的节点增长才需要补偿 let heightAddedAbove = 0; for (let i = 0; i < allOldNodes.length; i++) { const before = nodeMetricsBefore[i]; const afterHeight = allOldNodes[i].offsetHeight; const delta = afterHeight - before.height; if (delta > 0 && before.bottom <= viewportTop) { heightAddedAbove += delta; } } scrollEl.scrollTop = scrollTopBefore + heightAddedAbove; startupPerfDebug.finish('boardQuickReply.step7.compensate', __t7, () => ({ nodes: allOldNodes.length, addedAbove: heightAddedAbove })); // Step 8: 后处理增强(所有匹配节点) const __t8 = startupPerfDebug.begin(); for (const node of allOldNodes) { try { if (typeof hideEmptyTitleAndEmail === 'function') hideEmptyTitleAndEmail($(node)); } catch (err) {} try { if (typeof markAllCookies === 'function') markAllCookies(getFilterConfig().markedGroups || [], node); } catch (err) {} } startupPerfDebug.finish('boardQuickReply.step8.post', __t8, () => ({ nodes: allOldNodes.length })); // 延迟执行其他增强 setTimeout(() => { // ★ deferred 只处理本次新增的回复(appendedNodes): // 旧回复在首载/前次刷新已处理过(各函数幂等锁保证不重复)。 // 原实现遍历 allOldNodes(整条串)→ enableHDImageAndLayoutFix 对整串所有元素 // 读布局+写样式(getBoundingClientRect×每元素,读 写 读 交错强制重排), // 再叠加 refreshFilterDisplay 全文档过滤重算——「插入后卡顿」的主源。 // 对齐串内页行为(applyPageEnhancements 只处理新增内容)。 if (!appendedNodes.length) return; const __td = startupPerfDebug.begin(); // ── A 组:需串级作用域(highlightPO 的楼层编号要遍历 .h-threads-item-replies 容器, // 以单条新回复为 scope 会空转 → 新回复无编号/无 Po 标,实测截图问题)。 // highlightPO 是纯 DOM 文本/样式写、无布局读,对整串跑成本低。 for (const node of allOldNodes) { try { if (typeof highlightPO === 'function') highlightPO(node); } catch (err) {} } // ── B 组:布局读写热点,严格限定本次新增回复(旧回复不再重跑—— // enableHDImageAndLayoutFix 的 handleGeneralElements 对每元素 // 读布局+写样式交错,整串重跑会强制重排数百次 = 插入后卡顿主源) for (const node of appendedNodes) { const replyScope = node.closest('.h-threads-item-reply') || node; try { if (cfg2 && cfg2.enableHDImageAndLayoutFix && typeof enableHDImageAndLayoutFix === 'function') enableHDImageAndLayoutFix(replyScope); } catch (err) {} try { if (cfg2 && cfg2.enableHDImage && typeof enableHDImage === 'function') enableHDImage(replyScope); } catch (err) {} try { if (cfg2 && cfg2.enableLinkBlank && typeof runLinkBlank === 'function') runLinkBlank(node); } catch (err) {} try { if (cfg2 && cfg2.extendQuote && typeof extendQuote === 'function') extendQuote(node); } catch (err) {} try { if (typeof initContent === 'function') initContent(node); } catch (err) {} try { if (typeof initExtendedContent === 'function') initExtendedContent(node); } catch (err) {} } // ── D 组:过滤与展开按钮按串节点跑(轻量 DOM 遍历/样式写,无布局读; // 新回复的过滤结果可能影响串的展开/折叠形态) const __tf = startupPerfDebug.begin(); for (const node of allOldNodes) { try { if (cfg2) refreshFilterDisplay(cfg2, node); } catch (err) {} } startupPerfDebug.finish('boardQuickReply.deferred.refreshFilter', __tf, () => ({ nodes: allOldNodes.length })); const __tp = startupPerfDebug.begin(); for (const node of allOldNodes) { try { if (typeof enablePostExpand === 'function') enablePostExpand(node); } catch (err) {} } startupPerfDebug.finish('boardQuickReply.deferred.enablePostExpand', __tp, () => ({ nodes: allOldNodes.length })); startupPerfDebug.finish('boardQuickReply.deferred.total', __td, () => ({ nodes: appendedNodes.length })); }, 50); if (fromButton) toast('已更新 ' + newReplies.length + ' 条回复'); if (e.type === 'tempReplySuccess') currentReplyTid = null; } catch (err) { console.warn('[board-quick-reply] API refresh failed', err); toast('刷新板块串失败'); } })().finally(() => { // ★ 整链收尾:含全部 await 等待与 Step 5-8 同步工作;与 LoAF 的 blockingDuration 对照 startupPerfDebug.finish('boardQuickReply.total', __perfToken, () => ({ fromButton })); if (btn) { btn.disabled = false; btn.classList.remove('xdex-icon-loading'); } if (fromButton) _boardRefreshInflight.delete(String(tid)); }); } // 构建 API 回复的 DOM 节点(匹配页面原生 .h-threads-item-reply 结构) function buildApiReplyNode(reply, threadId, opUserHash) { if (!reply || !reply.id) return null; const _e = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); const id = String(reply.id); const title = String(reply.title || ''); const email = String(reply.email || ''); const now = String(reply.now || ''); const hash = String(reply.user_hash || ''); const contentHtml = String(reply.content || ''); const imgRaw = String(reply.img || ''); const extRaw = String(reply.ext || ''); const suf = extRaw ? (extRaw[0] === '.' ? extRaw : '.' + extRaw) : ''; const fullUrl = imgRaw ? 'https://image.nmb.best/image/' + imgRaw + suf : ''; const thumbUrl = imgRaw ? 'https://image.nmb.best/thumb/' + imgRaw + suf : ''; // 图片区(含工具栏) const imgHtml = imgRaw ? '<div class="h-threads-img-box">' + '<div class="h-threads-img-tool uk-animation-slide-top">' + '<span class="h-threads-img-tool-btn h-threads-img-tool-small uk-button-link"><i class="uk-icon-minus"></i>收起</span>' + '<a href="' + _e(fullUrl) + '" target="_blank" class="h-threads-img-tool-btn uk-button-link"><i class="uk-icon-search-plus"></i>查看大图</a>' + '<span class="h-threads-img-tool-btn h-threads-img-tool-left uk-button-link"><i class="uk-icon-reply"></i>向左旋转</span>' + '<span class="h-threads-img-tool-btn h-threads-img-tool-right uk-button-link"><i class="uk-icon-share"></i>向右旋转</span>' + '</div>' + '<a href="' + _e(fullUrl) + '" rel="_blank" target="_blank" class="h-threads-img-a">' + '<img data-src="' + _e(thumbUrl) + '" src="' + _e(thumbUrl) + '" align="left" border="0" hspace="20" class="h-threads-img">' + '</a>' + '</div>' : ''; // 信息区 const uidHtml = hash ? '<span class="h-threads-info-uid" data-xdex-cookie-id="' + _e(hash) + '">ID:' + _e(hash) + '</span>' : ''; // PO 主判定: 与串的 OP 饼干哈希一致时插入 (PO主) 标记, 供 highlightPO 识别替换 Po 图标 const poMarkHtml = (opUserHash && hash && hash === opUserHash) ? ' <span class="uk-text-primary uk-text-small">(PO主)</span>' : ''; const titleText = (title && title !== '无标题') ? _e(title) : '无标题'; const emailText = (email && email !== '无名氏') ? _e(email) : '无名氏'; const html = '<div data-threads-id="' + _e(id) + '" class="h-threads-item-reply">' + '<div class="h-threads-item-reply-icon">…</div>' + '<div class="h-threads-item-reply-main">' + imgHtml + '<div class="h-threads-info">' + '<span class="h-threads-info-title">' + titleText + '</span>' + '<span class="h-threads-info-email">' + emailText + '</span>' + '<span class="h-threads-info-createdat">' + _e(now) + '</span>' + uidHtml + poMarkHtml + '<span class="h-threads-info-report-btn">[<a href="/f/值班室?r=' + _e(id) + '">举报</a>]</span>' + '<a href="/t/' + _e(threadId) + '?r=' + _e(id) + '" class="h-threads-info-id">No.' + _e(id) + '</a>' + '</div>' + '<div class="h-threads-content">' + contentHtml + '</div>' + '</div>' + '</div>'; // ★ 逐条计时:innerHTML 解析成本。Step 6 内循环调用,条数多时累加可观 const __tok = startupPerfDebug.begin(); const tmp = document.createElement('div'); tmp.innerHTML = html; startupPerfDebug.finish('boardQuickReply.buildNode', __tok, () => ({ html: html.length, hasImg: !!imgRaw })); return tmp.firstChild || null; } } // 统一调用 bindReplyQuoteLinks(); bindBoardQuickReplyRefresh(); bindBoardThreadRefreshButtons(document); } // tag 15 子功能:板块页/时间线串卡片「获取最新回复」按钮 // 复用 handleBoardQuickReplyRefresh 的增量刷新;放在展开/收起按钮(.js-toggle-mode)之后 function bindBoardThreadRefreshButtons(root) { if (!PageType.isBoardPage() && !PageType.isTimelinePage()) return; const scanRoot = (root && typeof root.querySelectorAll === 'function') ? root : document; const items = []; if (scanRoot.classList && scanRoot.classList.contains('h-threads-item-index')) items.push(scanRoot); scanRoot.querySelectorAll('.h-threads-item-index').forEach(it => { if (items.indexOf(it) < 0) items.push(it); }); items.forEach(item => { const infoBar = item.querySelector('.h-threads-info'); if (!infoBar) return; let tid = item.getAttribute('data-threads-id'); if (!tid) { const idEl = infoBar.querySelector('.h-threads-info-id'); const m = idEl && (idEl.textContent || '').match(/\d+/); if (m) tid = m[0]; } if (!tid) return; let btn = infoBar.querySelector(':scope > .xdex-board-refresh-btn'); if (!btn) { btn = document.createElement('button'); btn.type = 'button'; btn.className = 'xdex-icon-btn xdex-board-refresh-btn'; btn.title = '获取最新回复'; btn.style.cssText = 'min-width:1em;text-align:center;margin-left:4px;vertical-align:middle;'; btn.setAttribute('data-tid', tid); btn.innerHTML = XDEX_ICON_SYNC; btn.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); const t = btn.getAttribute('data-tid'); if (!t) return; document.dispatchEvent(new CustomEvent('xdexBoardThreadRefresh', { detail: { tid: t, fromButton: true, btn: btn } })); }); infoBar.appendChild(btn); } else { btn.setAttribute('data-tid', tid); } // 定位到展开/收起按钮之前(本按钮加载更快,置于左侧;enablePostExpand 可能晚于本函数执行,故每次重排) const toggle = infoBar.querySelector(':scope > .js-toggle-mode'); if (toggle && toggle.previousElementSibling !== btn) toggle.insertAdjacentElement('beforebegin', btn); }); } // === 通用:确保某个元素被折叠(幂等) === function ensureCollapsed($elem, hint) { if (!$elem || !$elem.length) return; if ($elem.data('xdex-collapsed')) return; // 已折叠过 Utils.collapse($elem, hint); } const postExpandDocScroller = document.scrollingElement || document.documentElement; function getScrollContainer(startEl) { let el = startEl || document.body; while (el && el !== document.body) { const style = getComputedStyle(el); const canScroll = /(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight; if (canScroll) return el; el = el.parentElement; } return postExpandDocScroller; } function getViewportRect(scroller) { if (scroller === postExpandDocScroller || scroller === document.body || scroller === document.documentElement) { return { top: 0, bottom: window.innerHeight }; } const r = scroller.getBoundingClientRect(); return { top: r.top, bottom: r.bottom }; } function isPartiallyInViewport(el, scroller) { if (!el) return false; const er = el.getBoundingClientRect(); const vr = getViewportRect(scroller); const visibleTop = Math.max(er.top, vr.top); const visibleBottom = Math.min(er.bottom, vr.bottom); return Math.max(0, visibleBottom - visibleTop) > 0; } function getElementAbsTop(el, scroller) { const er = el.getBoundingClientRect(); if (scroller === postExpandDocScroller || scroller === document.body || scroller === document.documentElement) { return er.top + window.scrollY; } const sr = scroller.getBoundingClientRect(); return er.top - sr.top + scroller.scrollTop; } function scrollByCompensation(scroller, dy) { if (!dy) return; if (scroller === postExpandDocScroller || scroller === document.body || scroller === document.documentElement) { window.scrollBy({ top: dy, left: 0, behavior: 'auto' }); } else { scroller.scrollTop += dy; } } function findNextThread(item) { let el = item.nextElementSibling; while (el) { if (el.classList && el.classList.contains('h-threads-item-index')) return el; el = el.nextElementSibling; } return null; } function collapseWithoutShift(item) { const postForm = document.getElementById('h-post-form'); if (postForm && isPartiallyInViewport(postForm, postExpandDocScroller)) { item.classList.remove('expanded'); return; } if (item.classList.contains('qp-reply-form') || item.classList.contains('xdex-generic-collapsed') || item.closest('.xdex-generic-toggle')) { item.classList.remove('expanded'); return; } const scroller = getScrollContainer(item); const scrollerIsDoc = (scroller === postExpandDocScroller || scroller === document.body || scroller === document.documentElement); const next = findNextThread(item); const nextVisible = isPartiallyInViewport(next, scroller); const threadContainer = scrollerIsDoc ? document : scroller; const allThreadsInScroller = Array.from(threadContainer.querySelectorAll('.h-threads-item-index')); const visibleThreads = allThreadsInScroller.filter(t => isPartiallyInViewport(t, scroller)); if (visibleThreads.length === 1 && visibleThreads[0] === item && next) { item.classList.remove('expanded'); requestAnimationFrame(() => { requestAnimationFrame(() => { try { const vr = getViewportRect(scroller); const vHeight = Math.max(1, vr.bottom - vr.top); const currentScroll = scrollerIsDoc ? window.scrollY : scroller.scrollTop; const nextAbsTop = getElementAbsTop(next, scroller); const desiredAbsTop = currentScroll + Math.floor(vHeight / 3); const delta = nextAbsTop - desiredAbsTop; if (delta !== 0) scrollByCompensation(scroller, delta); } catch (err) { try { const preScroll = scrollerIsDoc ? window.scrollY : scroller.scrollTop; const preHeight = scroller.scrollHeight; requestAnimationFrame(() => { const postHeight = scroller.scrollHeight; const d = preHeight - postHeight; if (d > 0) { const target = Math.max(0, preScroll - d); if (scrollerIsDoc) window.scrollTo({ top: target, left: 0, behavior: 'auto' }); else scroller.scrollTop = target; } }); } catch (e2) { console.warn('collapseWithoutShift special-case fallback 异常:', e2); } } }); }); return; } if (nextVisible) { const preScroll = scrollerIsDoc ? window.scrollY : scroller.scrollTop; const preHeight = scroller.scrollHeight; item.classList.remove('expanded'); requestAnimationFrame(() => { const postHeight = scroller.scrollHeight; const delta = preHeight - postHeight; if (delta > 0) { const target = Math.max(0, preScroll - delta); if (scrollerIsDoc) window.scrollTo({ top: target, left: 0, behavior: 'auto' }); else scroller.scrollTop = target; } }); return; } const anchor = item; const preAbsTop = getElementAbsTop(anchor, scroller); item.classList.remove('expanded'); requestAnimationFrame(() => { requestAnimationFrame(() => { const postAbsTop = getElementAbsTop(anchor, scroller); const delta = preAbsTop - postAbsTop; if (delta !== 0) { scrollByCompensation(scroller, delta); } else { try { const preScroll = scrollerIsDoc ? window.scrollY : scroller.scrollTop; const preHeight = scroller.scrollHeight; requestAnimationFrame(() => { const postHeight = scroller.scrollHeight; const d = preHeight - postHeight; if (d > 0) { const target = Math.max(0, preScroll - d); if (scrollerIsDoc) window.scrollTo({ top: target, left: 0, behavior: 'auto' }); else scroller.scrollTop = target; } }); } catch (err) { console.warn('collapseWithoutShift fallback 异常:', err); } } }); }); } /* -------------------------------------------------- * tag 16. 允许展开/收起被板块页折叠的长串 * -------------------------------------------------- */ function enablePostExpand(root = document) { const scanRoot = root && typeof root.querySelectorAll === 'function' ? root : document; const scanTargets = []; if (scanRoot.classList && scanRoot.classList.contains('h-threads-item-index')) { scanTargets.push(scanRoot); } scanRoot.querySelectorAll('.h-threads-item-index').forEach(item => { if (!scanTargets.includes(item)) scanTargets.push(item); }); // 注入样式(同前,保留 overflow-anchor: none) (function ensureExpandedStyle() { const id = 'h-expanded-style'; if (document.getElementById(id)) return; const style = document.createElement('style'); style.id = id; style.textContent = ` html, body { overflow-y: scroll !important; } .h-threads-item-index.expanded, html.xdex-post-expand-all .h-threads-item-index:not(.xdex-post-expand-collapsed) { max-height: none !important; overflow-y: scroll !important; overflow-anchor: none !important; } `; document.head.appendChild(style); })(); let lastExpandedItem = null; // 根据 SettingPanel.state 读取是否开启“全部展开”模式;用根节点 class 表达全局展开,避免逐串同步改 class 堵塞版块页 const expandAllMode = !!(typeof SettingPanel !== 'undefined' && SettingPanel.state && SettingPanel.state.enablePostExpandAll); document.documentElement.classList.toggle('xdex-post-expand-all', expandAllMode); // 外部点击收起:兼容两种模式(常规 / 全部展开) function outsideHandler(e) { // 忽略公用折叠占位符点击 if (e.target.closest('.xdex-placeholder.xdex-generic-toggle')) return; const cfg = (typeof SettingPanel !== 'undefined' && SettingPanel.state) ? SettingPanel.state : {}; const isAllMode = !!cfg.enablePostExpandAll; if (!isAllMode) { // 必须在 #h-content 内 const hContent = document.getElementById('h-content'); if (!hContent || !hContent.contains(e.target)) return; // 找到所有 .h-threads-list,判断点击是否在任一 list 的垂直范围内 const threadLists = document.querySelectorAll('#h-content .h-threads-list'); if (!threadLists.length) return; const x = e.clientX; const y = e.clientY; let matchedListRect = null; let isInAnyList = false; for (const list of threadLists) { const listRect = list.getBoundingClientRect(); if (y >= listRect.top && y <= listRect.bottom) { isInAnyList = true; matchedListRect = listRect; break; } } if (!isInAnyList) return; if (!matchedListRect) return; if (y < matchedListRect.top || y > matchedListRect.bottom) return; // 点击在 uk-container 内部不处理(排除串内部及其包裹容器) if (e.target.closest('.uk-container')) return; // 如果没有记录到最后被展开的串,则不处理 if (!lastExpandedItem) return; // 确保 lastExpandedItem 仍在 DOM 中 if (!document.contains(lastExpandedItem)) { lastExpandedItem = null; return; } // 获取被展开串的 rect const rect = lastExpandedItem.getBoundingClientRect(); // 若点击纵坐标不在该串的垂直范围内,则不收起 const V_TOL = 2; // 垂直容差(像素) if (y < rect.top - V_TOL || y > rect.bottom + V_TOL) return; // 若点击点在该串的水平内部范围内,不收起;只在左右明确外围才收起 if (x >= rect.left && x <= rect.right) return; // 排除点击在上方兄弟元素内 let p = lastExpandedItem.parentElement; while (p && p !== document.body) { for (let sib = p.firstElementChild; sib && sib !== lastExpandedItem; sib = sib.nextElementSibling) { if (sib.contains(e.target)) { return; } } if (p.classList && p.classList.contains('h-threads-list')) break; p = p.parentElement; } // 触发收起 const btn = lastExpandedItem.querySelector('.h-threads-info .js-toggle-mode'); if (btn) setPostExpandToggleVisual(btn, false); collapseWithoutShift(lastExpandedItem); lastExpandedItem = null; return; } // ===== 全部展开模式保留原逻辑,但同样采用“纵向带 + 排除 uk-container” ===== const hContent = document.getElementById('h-content'); if (!hContent || !hContent.contains(e.target)) return; const threadLists = document.querySelectorAll('#h-content .h-threads-list'); if (!threadLists.length) return; const y = e.clientY; let isInAnyList = false; for (const list of threadLists) { const listRect = list.getBoundingClientRect(); if (y >= listRect.top && y <= listRect.bottom) { isInAnyList = true; break; } } if (!isInAnyList) return; // 点击在 uk-container 内部不处理 if (e.target.closest('.uk-container')) return; const threads = Array.from(document.querySelectorAll('.h-threads-item-index')); if (!threads.length) return; let target = threads.find(t => { const r = t.getBoundingClientRect(); return y >= r.top && y <= r.bottom; }); if (!target) { let min = Infinity; for (const t of threads) { const r = t.getBoundingClientRect(); const d = Math.min(Math.abs(y - r.top), Math.abs(y - r.bottom)); if (d < min) { min = d; target = t; } } } if (target && !target.classList.contains('xdex-post-expand-collapsed')) { const btn = target.querySelector('.h-threads-info .js-toggle-mode'); if (btn) setPostExpandToggleVisual(btn, false); target.classList.add('xdex-post-expand-collapsed'); collapseWithoutShift(target); } } // 添加按钮 scanTargets.forEach(item => { // === 新增:跳过主串自身被折叠的情况(不包括串内回复被折叠) === // 只检查 item 的直接子元素中是否有折叠占位符,不深入检查回复区域 const directPlaceholder = Array.from(item.children).find(child => child.classList.contains('xdex-placeholder') && child.classList.contains('xdex-generic-toggle') ); if (directPlaceholder) return; const infoBar = item.querySelector('.h-threads-info'); if (!infoBar) return; if (infoBar.querySelector('.js-toggle-mode')) return; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'js-toggle-mode xdex-icon-btn'; btn.style.cssText = 'min-width:1em;text-align:center;vertical-align:middle;margin-left:4px;'; setPostExpandToggleVisual(btn, expandAllMode && !item.classList.contains('xdex-post-expand-collapsed')); btn.addEventListener('click', e => { e.stopPropagation(); const isAllMode = document.documentElement.classList.contains('xdex-post-expand-all'); if (isAllMode) { const willExpand = item.classList.contains('xdex-post-expand-collapsed'); if (willExpand) { item.classList.remove('xdex-post-expand-collapsed'); setPostExpandToggleVisual(btn, true); lastExpandedItem = item; } else { setPostExpandToggleVisual(btn, false); item.classList.add('xdex-post-expand-collapsed'); collapseWithoutShift(item); if (lastExpandedItem === item) lastExpandedItem = null; } return; } const willExpand = !item.classList.contains('expanded'); if (willExpand) { item.classList.add('expanded'); setPostExpandToggleVisual(btn, true); lastExpandedItem = item; } else { setPostExpandToggleVisual(btn, false); collapseWithoutShift(item); if (lastExpandedItem === item) lastExpandedItem = null; } }); infoBar.appendChild(btn); }); // 绑定外部点击 const hContent = document.getElementById('h-content'); if (hContent) { hContent.removeEventListener('click', outsideHandler, true); hContent.addEventListener('click', outsideHandler, true); } } /* -------------------------------------------------- * tag 17. 替换搜索按钮为野生搜索酱(来自4sYbzEX https://www.nmbxd.com/t/64792841) * -------------------------------------------------- */ function searchServiceBy4sY() { const btn = document.querySelector('#h-menu-search-keyword'); if (!btn) return; // 若按钮不存在则不处理 // 移除原有的 href 与默认跳转行为 btn.removeAttribute('href'); btn.removeEventListener('click', btn._4sYHandler || (()=>{})); // 定义新的点击事件 const handler = (e) => { e.preventDefault(); window.open('https://nmb-search.166666666.xyz/', '_blank'); }; // 绑定事件 btn.addEventListener('click', handler); // 保存 handler 以便后续移除时使用(防止重复绑定) btn._4sYHandler = handler; } function isNmbSearchPage() { return location.hostname === 'nmb-search.166666666.xyz'; } function normalizeNmbSearchMobileThreadUrl(rawHref) { let url; try { url = new URL(rawHref, location.href); } catch (e) { return rawHref; } if (url.protocol !== 'https:' || url.hostname !== 'www.nmbxd1.com') return rawHref; const nextPath = url.pathname.replace(/^\/m\/t\/(\d+)/, '/t/$1'); if (nextPath === url.pathname) return rawHref; url.pathname = nextPath; return url.toString(); } function makeNmbSearchLinkOpenInNewTab(a) { a.setAttribute('target', '_blank'); const rel = new Set(String(a.getAttribute('rel') || '').split(/\s+/).filter(Boolean)); rel.add('noopener'); rel.add('noreferrer'); a.setAttribute('rel', Array.from(rel).join(' ')); } function isNmbSearchResultLink(a) { return !!(a && a.matches && a.matches('#overflow a[href]')); } function rewriteNmbSearchMobileThreadLinks(root = document) { if (!isNmbSearchPage()) return; const selector = '#overflow a[href]'; const links = []; if (root && root.matches && root.matches(selector)) links.push(root); if (root && root.querySelectorAll) { root.querySelectorAll(selector).forEach(a => links.push(a)); } links.forEach(a => { const rawHref = a.getAttribute('href') || a.href || ''; const nextHref = normalizeNmbSearchMobileThreadUrl(rawHref); if (nextHref !== rawHref) a.setAttribute('href', nextHref); makeNmbSearchLinkOpenInNewTab(a); }); } function handleNmbSearchLinkClick(e) { if (!isNmbSearchPage() || e.defaultPrevented || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; const a = e.target && e.target.closest ? e.target.closest('a[href]') : null; if (!a) return; if (!isNmbSearchResultLink(a)) return; const rawHref = a.getAttribute('href') || a.href || ''; if (!rawHref || /^javascript:/i.test(rawHref)) return; const nextHref = normalizeNmbSearchMobileThreadUrl(rawHref); if (nextHref !== rawHref) a.setAttribute('href', nextHref); makeNmbSearchLinkOpenInNewTab(a); e.preventDefault(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); window.open(a.href, '_blank', 'noopener,noreferrer'); } function initNmbSearchMobileThreadRedirector() { if (!isNmbSearchPage()) return; rewriteNmbSearchMobileThreadLinks(document); if (!initNmbSearchMobileThreadRedirector.clickHandlerInstalled) { document.addEventListener('click', handleNmbSearchLinkClick, true); initNmbSearchMobileThreadRedirector.clickHandlerInstalled = true; } if (initNmbSearchMobileThreadRedirector.observer || !document.body) return; const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node && node.nodeType === 1) rewriteNmbSearchMobileThreadLinks(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); initNmbSearchMobileThreadRedirector.observer = observer; } /* -------------------------------------------------- * tag 18. 侧栏收起(来自acVMxuvhttps://greasyfork.org/zh-CN/scripts/553143-x%E5%B2%9B%E4%BC%98%E5%8C%96%E5%B2%9B-%E4%BE%A7%E8%BE%B9%E6%A0%8F%E4%BC%98%E5%8C%96%E7%89%88) * -------------------------------------------------- */ function toggleSidebar() { // --- 侧边栏自动收起功能 --- const hMenuDiv = document.getElementById('h-menu'); if (hMenuDiv) { // 1. 创建一个新的触发区域元素 const triggerZone = document.createElement('div'); // 2. 设置触发区域的样式 // 它将是一个位于屏幕左侧的、透明的、固定宽度的垂直条 triggerZone.style.position = 'fixed'; triggerZone.style.left = '0'; triggerZone.style.top = '0'; triggerZone.style.width = '100px'; // 触发区域宽度,可以根据需要调整 triggerZone.style.height = '100vh'; // 高度占满整个屏幕 triggerZone.style.zIndex = '800'; // 确保它在大多数元素之上,但在菜单之下 // triggerZone.style.backgroundColor = 'rgba(255, 0, 0, 0.2)'; // 取消此行注释以可视化触发区域 // 3. 将触发区域添加到页面中 document.body.appendChild(triggerZone); // 4. 设置侧边栏的初始样式为折叠 hMenuDiv.style.transition = 'width 0.3s ease'; // 添加平滑过渡效果 hMenuDiv.style.width = '0'; hMenuDiv.style.overflow = 'hidden'; hMenuDiv.style.zIndex = '999'; // 确保菜单在触发区域之上 // 5. 为新的触发区域添加鼠标悬停事件,用于展开侧边栏 triggerZone.addEventListener('mouseover', function() { hMenuDiv.style.width = '200px'; // 展开侧边栏 hMenuDiv.style.overflow = 'visible'; }); // 6. 为侧边栏本身添加鼠标移出事件,用于收起侧边栏 hMenuDiv.addEventListener('mouseleave', function() { hMenuDiv.style.width = '0'; // 收起侧边栏 hMenuDiv.style.overflow = 'hidden'; }); } } /* -------------------------------------------------- * tag 19. 替换顶栏图片点击事件,串内刷新,板块页回到首页 * -------------------------------------------------- */ function overrideTopImageClick() { const topImgLink = document.querySelector('#h-menu-top-img'); if (!topImgLink) return false; // 幂等:early / ready 可能各跑一次 if (topImgLink.dataset.xdexTopImgClickBound === '1') return true; topImgLink.dataset.xdexTopImgClickBound = '1'; // 判断是否是串内页 function isThreadPage(path) { return /\/t\/\d{4,}/.test(path) || /^\/Forum\/po\/id\/\d+/.test(path); } // 判断是否是板块页 function isBoardPage(path) { return /^\/f\//.test(path) || /^\/Forum\/timeline\/id\/\d+/.test(path); } // capture 阶段抢先,避免原站默认跳转先触发 topImgLink.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); const path = location.pathname; let url = location.href; if (isThreadPage(path)) { // 串内页:刷新当前页面 location.reload(); } else if (isBoardPage(path)) { // 板块页:跳转到第一页 if (/\/Forum\/timeline\/id\/\d+\/page\/\d+\.html/.test(url)) { url = url.replace(/\/page\/\d+\.html/, '/page/1.html'); } else if (/[?&]page=\d+/.test(url)) { url = url.replace(/([?&])page=\d+/, '$1page=1'); } else if (/\/Forum\/timeline\/id\/\d+/.test(path) || /^\/f\//.test(path)) { // 已是第一页或无 page 参数:仍强制回第一页语义 try { const u = new URL(url, location.origin); u.searchParams.set('page', '1'); url = u.toString(); } catch (_) {} } location.href = url; } else { // 其他情况:跳转首页 location.href = '/'; } }, true); return true; } // early 安装:顶栏节点一出现就绑;若尚未出现则短时观察,避免等 document.ready/batch2 function installOverrideTopImageClickEarly() { if (installOverrideTopImageClickEarly.__done) return; if (overrideTopImageClick()) { installOverrideTopImageClickEarly.__done = true; return; } if (installOverrideTopImageClickEarly.__observing) return; const root = document.documentElement || document; if (!root || typeof MutationObserver === 'undefined') return; installOverrideTopImageClickEarly.__observing = true; const obs = new MutationObserver(() => { if (overrideTopImageClick()) { installOverrideTopImageClickEarly.__done = true; installOverrideTopImageClickEarly.__observing = false; try { obs.disconnect(); } catch (_) {} } }); try { obs.observe(root, { childList: true, subtree: true }); } catch (_) { installOverrideTopImageClickEarly.__observing = false; } // 兜底:最长观察 8s,避免永久挂着 observer setTimeout(() => { if (installOverrideTopImageClickEarly.__done) return; try { obs.disconnect(); } catch (_) {} installOverrideTopImageClickEarly.__observing = false; overrideTopImageClick(); }, 8000); } /* -------------------------------------------------- * tag 20. 图片显示模式:默认/模糊/无图/Tips * -------------------------------------------------- */ function applyImageHideMode(mode = 'default', root = document) { const isImageHideExcludedPage = location.hostname === 'www.nmbxd1.com' && (location.pathname.startsWith('/Member/') || location.pathname.startsWith('/Forum')); if (isImageHideExcludedPage) return; return startupPerfDebug.measure('applyImageHideMode', () => { const COVER_URL = 'https://moetu.org/xdchan/cover.php?from=index'; const VALID_MODES = new Set(['default', 'blur', 'noimage', 'tips', 'none']); const finalMode = VALID_MODES.has(mode) ? mode : 'default'; const scope = root && root.querySelectorAll ? root : document; const coverCache = applyImageHideMode.__coverCache || (applyImageHideMode.__coverCache = []); const coverCacheSet = applyImageHideMode.__coverCacheSet || (applyImageHideMode.__coverCacheSet = new Set()); const COVER_CACHE_MAX = 80; // 样式只注入一次 if (!document.getElementById('xdex-image-hide-style')) { const style = document.createElement('style'); style.id = 'xdex-image-hide-style'; style.textContent = ` /* 模式1:模糊遮罩 clip-path: inset(0) 裁剪 filter 产生的合成层溢出(overflow:hidden 做不到) */ .h-threads-img-box.xdex-hide-blur:not(.h-active) .h-threads-img { filter: blur(14px) brightness(0.5); clip-path: inset(0); transition: filter .15s ease; } /* 悬浮时取消遮罩 */ .h-threads-img-box.xdex-hide-blur:not(.h-active):hover .h-threads-img { filter: none; clip-path: none; } /* 放大(h-active)时始终不遮罩 */ .h-threads-img-box.xdex-hide-blur.h-active .h-threads-img { filter: none !important; clip-path: none !important; } /* 独立 img(无容器时) */ img.xdex-hide-blur-img { filter: blur(14px) brightness(0.5); clip-path: inset(0); transition: filter .15s ease; } img.xdex-hide-blur-img:hover { filter: none; clip-path: none; } /* 模式2:无图(隐藏,不删除 DOM) */ .h-threads-img-box.xdex-hide-noimage { display: none !important; } img.xdex-hide-noimage-img { display: none !important; } `; document.head.appendChild(style); } function getAllImgs(target) { const imgs = []; if (target instanceof HTMLImageElement) imgs.push(target); if (target && target.querySelectorAll) { target.querySelectorAll('img').forEach(img => imgs.push(img)); } return imgs; } function addCoverToCache(url) { if (!url || coverCacheSet.has(url)) return; coverCacheSet.add(url); coverCache.push(url); if (coverCache.length > COVER_CACHE_MAX) { const removed = coverCache.shift(); if (removed) coverCacheSet.delete(removed); } } function removeCoverFromCache(url) { if (!url || !coverCacheSet.has(url)) return; coverCacheSet.delete(url); const idx = coverCache.indexOf(url); if (idx >= 0) coverCache.splice(idx, 1); } function buildNewCoverUrl(idx) { return `${COVER_URL}&xdex=${Date.now()}_${idx}_${Math.random().toString(36).slice(2)}`; } function pickCoverUrl(idx, triedSet) { // 优先复用缓存 URL,降低重复请求压力 const usableCached = coverCache.filter(u => !triedSet.has(u)); if (usableCached.length > 0) { const u = usableCached[Math.floor(Math.random() * usableCached.length)]; return { url: u, fromCache: true }; } return { url: buildNewCoverUrl(idx), fromCache: false }; } function isExcludedImage(img) { if (!img || !img.closest) return false; try { return !!img.closest([ '#h-bottom-nav', '#h-menu-top', '#h-preview-box', '.h-preview-box', '#sp_btn', '.xdex-image-viewer-btn', '.h-threads-item-reply[data-threads-id="9999999"]' ].join(', ')); } catch (e) { // 选择器异常时兜底,避免中断后续逻辑 return false; } } function restoreNoImage(img) { if (!img || img.dataset.xdexNoImgApplied !== '1') return; const box = img.closest('.h-threads-img-box'); if (box) { box.classList.remove('xdex-hide-noimage'); box.dataset.xdexNoImgBox = '0'; } img.classList.remove('xdex-hide-noimage-img'); img.dataset.xdexNoImgImg = '0'; delete img.dataset.xdexNoImgApplied; } function restoreReplacedImage(img) { if (!img || img.dataset.xdexHideReplaceApplied !== '1') return; const origSrc = img.dataset.xdexOrigSrc; const origSrcset = img.dataset.xdexOrigSrcset; const origSizes = img.dataset.xdexOrigSizes; const origAlt = img.dataset.xdexOrigAlt; const origTitle = img.dataset.xdexOrigTitle; const origStyle = img.dataset.xdexOrigStyle; if (origSrc) img.setAttribute('src', origSrc); else img.removeAttribute('src'); if (origSrcset) img.setAttribute('srcset', origSrcset); else img.removeAttribute('srcset'); if (origSizes) img.setAttribute('sizes', origSizes); else img.removeAttribute('sizes'); if (origAlt) img.setAttribute('alt', origAlt); else img.removeAttribute('alt'); if (origTitle) img.setAttribute('title', origTitle); else img.removeAttribute('title'); if (origStyle) img.setAttribute('style', origStyle); else img.removeAttribute('style'); const box = img.closest('.h-threads-img-box'); if (box) box.dataset.xdexHideReplaceBox = '0'; if (img.__xdexRestoreOnClick) { img.removeEventListener('click', img.__xdexRestoreOnClick, true); img.__xdexRestoreOnClick = null; } img.onload = null; img.onerror = null; delete img.dataset.xdexHideReplaceApplied; // 恢复懒加载属性 if (img.dataset.xdexOrigHdSrc) { img.dataset.xdexHdSrc = img.dataset.xdexOrigHdSrc; delete img.dataset.xdexOrigHdSrc; } if (img.dataset.xdexOrigThumbSrc) { img.dataset.xdexThumbSrc = img.dataset.xdexOrigThumbSrc; delete img.dataset.xdexOrigThumbSrc; } delete img.dataset.xdexOrigSrc; delete img.dataset.xdexOrigSrcset; delete img.dataset.xdexOrigSizes; delete img.dataset.xdexOrigAlt; delete img.dataset.xdexOrigTitle; delete img.dataset.xdexOrigStyle; delete img.dataset.xdexLockWidth; delete img.dataset.xdexLockHeight; } function clearBlurMarks(target) { if (!target || !target.querySelectorAll) return; target.querySelectorAll('.h-threads-img-box.xdex-hide-blur').forEach(box => { box.classList.remove('xdex-hide-blur'); box.dataset.xdexHideBlurBox = '0'; }); target.querySelectorAll('img.xdex-hide-blur-img').forEach(img => { img.classList.remove('xdex-hide-blur-img'); img.dataset.xdexHideBlurImg = '0'; }); } function clearNoImageMarks(target) { if (!target || !target.querySelectorAll) return; target.querySelectorAll('.h-threads-img-box.xdex-hide-noimage').forEach(box => { box.classList.remove('xdex-hide-noimage'); box.dataset.xdexNoImgBox = '0'; }); target.querySelectorAll('img.xdex-hide-noimage-img').forEach(img => { img.classList.remove('xdex-hide-noimage-img'); img.dataset.xdexNoImgImg = '0'; }); target.querySelectorAll('img[data-xdex-no-img-applied="1"]').forEach(restoreNoImage); } function applyBlur(img) { const box = img.closest('.h-threads-img-box'); // blur 模式下若之前替换过,先恢复原图 if (img.dataset.xdexHideReplaceApplied === '1') { restoreReplacedImage(img); } // blur 模式下若之前处于无图,先恢复 if (img.dataset.xdexNoImgApplied === '1') { restoreNoImage(img); } if (box) { if (box.dataset.xdexHideBlurBox === '1') return; box.classList.add('xdex-hide-blur'); box.dataset.xdexHideBlurBox = '1'; return; } if (img.dataset.xdexHideBlurImg === '1') return; img.classList.add('xdex-hide-blur-img'); img.dataset.xdexHideBlurImg = '1'; } function applyNoImage(img) { const box = img.closest('.h-threads-img-box'); if (img.dataset.xdexHideReplaceApplied === '1') { restoreReplacedImage(img); } if (box) { box.classList.remove('xdex-hide-blur'); box.dataset.xdexHideBlurBox = '0'; } img.classList.remove('xdex-hide-blur-img'); img.dataset.xdexHideBlurImg = '0'; if (img.dataset.xdexNoImgApplied === '1') return; img.dataset.xdexNoImgApplied = '1'; if (box) { box.classList.add('xdex-hide-noimage'); box.dataset.xdexNoImgBox = '1'; } else { img.classList.add('xdex-hide-noimage-img'); img.dataset.xdexNoImgImg = '1'; } } function applyReplace(img, idx) { const box = img.closest('.h-threads-img-box'); // Tips 模式下若之前处于无图,先恢复 if (img.dataset.xdexNoImgApplied === '1') { restoreNoImage(img); } // Tips 模式下移除 blur 标记 if (box) { box.classList.remove('xdex-hide-blur'); box.dataset.xdexHideBlurBox = '0'; box.classList.remove('xdex-hide-noimage'); box.dataset.xdexNoImgBox = '0'; } img.classList.remove('xdex-hide-blur-img'); img.dataset.xdexHideBlurImg = '0'; img.classList.remove('xdex-hide-noimage-img'); img.dataset.xdexNoImgImg = '0'; if (img.dataset.xdexHideReplaceApplied === '1') return; // 锁定当前显示尺寸,防止替换图挤压回复区域 const rect = img.getBoundingClientRect(); const cs = window.getComputedStyle(img); const lockW = Math.round(rect.width || parseFloat(cs.width) || 0); const lockH = Math.round(rect.height || parseFloat(cs.height) || 0); img.dataset.xdexOrigSrc = img.getAttribute('src') || ''; img.dataset.xdexOrigSrcset = img.getAttribute('srcset') || ''; img.dataset.xdexOrigSizes = img.getAttribute('sizes') || ''; img.dataset.xdexOrigAlt = img.getAttribute('alt') || ''; img.dataset.xdexOrigTitle = img.getAttribute('title') || ''; img.dataset.xdexOrigStyle = img.getAttribute('style') || ''; img.dataset.xdexLockWidth = lockW > 0 ? String(lockW) : ''; img.dataset.xdexLockHeight = lockH > 0 ? String(lockH) : ''; img.dataset.xdexHideReplaceApplied = '1'; // 暂存并清除懒加载属性,防止懒加载器覆盖 tips 图 if (img.dataset.xdexHdSrc) { img.dataset.xdexOrigHdSrc = img.dataset.xdexHdSrc; delete img.dataset.xdexHdSrc; } if (img.dataset.xdexThumbSrc) { img.dataset.xdexOrigThumbSrc = img.dataset.xdexThumbSrc; } delete img.dataset.xdexHdLoaded; delete img.dataset.xdexHdLoading; if (box) box.dataset.xdexHideReplaceBox = '1'; // 不预加载原图:仅保存原链接,点击恢复时再加载 img.removeAttribute('srcset'); img.removeAttribute('sizes'); img.setAttribute('title', 'Tips'); img.setAttribute('alt', 'Tips'); if (lockW > 0) { img.style.width = `${lockW}px`; img.style.minWidth = `${lockW}px`; img.style.maxWidth = `${lockW}px`; } else { img.style.maxWidth = '100%'; } if (lockH > 0) { img.style.height = `${lockH}px`; } else { img.style.height = 'auto'; } img.style.maxHeight = '60vh'; img.style.objectFit = 'cover'; const tried = new Set(); const applyCoverWithRetry = (remain = 6) => { if (img.dataset.xdexHideReplaceApplied !== '1') return; const picked = pickCoverUrl(idx, tried); const fakeUrl = picked.url; tried.add(fakeUrl); img.onload = () => { addCoverToCache(fakeUrl); img.onload = null; img.onerror = null; }; img.onerror = () => { removeCoverFromCache(fakeUrl); if (remain <= 0) { img.onload = null; img.onerror = null; return; } // 失败后立即尝试下一张(优先缓存池,其次新 URL) applyCoverWithRetry(remain - 1); }; img.setAttribute('src', fakeUrl); }; applyCoverWithRetry(); // 首次点击:仅恢复原图;后续点击回归原逻辑 const restoreOnFirstClick = (e) => { if (img.dataset.xdexHideReplaceApplied !== '1') return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); restoreReplacedImage(img); }; img.__xdexRestoreOnClick = restoreOnFirstClick; img.addEventListener('click', restoreOnFirstClick, true); } if (finalMode === 'none' || finalMode === 'default') { clearBlurMarks(document); clearNoImageMarks(document); document.querySelectorAll('img[data-xdex-hide-replace-applied="1"]').forEach(restoreReplacedImage); window.__xdexImageHideMode = 'default'; try { if (typeof syncImageViewerButtonForHideMode === 'function') { syncImageViewerButtonForHideMode('default'); } } catch (e) {} return; } const imgs = getAllImgs(scope); imgs.forEach((img, idx) => { // 排除顶栏和底栏导航中的图片,不参与替换/模糊 if (isExcludedImage(img)) { if (img.dataset.xdexHideReplaceApplied === '1') restoreReplacedImage(img); if (img.dataset.xdexNoImgApplied === '1') restoreNoImage(img); img.classList.remove('xdex-hide-blur-img'); img.dataset.xdexHideBlurImg = '0'; img.classList.remove('xdex-hide-noimage-img'); img.dataset.xdexNoImgImg = '0'; const box = img.closest('.h-threads-img-box'); if (box) { box.classList.remove('xdex-hide-blur'); box.classList.remove('xdex-hide-noimage'); box.dataset.xdexHideBlurBox = '0'; box.dataset.xdexNoImgBox = '0'; box.dataset.xdexHideReplaceBox = '0'; } return; } // 跳过无 src 的占位图(但保留 h-threads-img 处理) if (!img.classList.contains('h-threads-img') && !(img.getAttribute('src') || '').trim()) return; if (finalMode === 'blur') applyBlur(img); else if (finalMode === 'noimage') applyNoImage(img); else if (finalMode === 'tips') applyReplace(img, idx); }); window.__xdexImageHideMode = finalMode; // 阅图入口与隐藏模式联动(无图隐藏可恢复 / 模糊遮罩 / Tips 换皮) try { if (typeof syncImageViewerButtonForHideMode === 'function') { syncImageViewerButtonForHideMode(finalMode); } } catch (e) {} }, () => Object.assign({ mode }, startupPerfDebug.summarizeRoot(root || document))); } // 暴露给外部:window.applyImageHideMode('default'|'blur'|'noimage'|'tips'|'none', root) window.applyImageHideMode = applyImageHideMode; /* -------------------------------------------------- * tag 21. 自动识别链接 * -------------------------------------------------- */ function runAutoUrlLinkify(root = document) { return startupPerfDebug.measure('runAutoUrlLinkify', () => { const scope = root && root.querySelectorAll ? root : document; const processedAttr = 'data-xdex-linkified'; const containerSelector = '.h-threads-content, .h-preview-box'; const protocolUrlRegex = /https?:\/\/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{2,5})?(?:\/[^\s<,。!?;:、】【)》」』、?#]*)*(?:\?[^\s<,。!?;:、】【)》」』、#]*)?(?:#[^\s<,。!?;:、】【)》」』、]*)?/gi; const wwwUrlRegex = /www\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{2,5})?(?:\/[^\s<,。!?;:、】【)》」』、?#]*)*(?:\?[^\s<,。!?;:、】【)》」』、#]*)?(?:#[^\s<,。!?;:、】【)》」』、]*)?/gi; const bareDomainScanRegex = /(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{2,5})?(?:\/[^\s<,。!?;:、】【)》」』、?#]*)*(?:\?[^\s<,。!?;:、】【)》」』、#]*)?(?:#[^\s<,。!?;:、】【)》」』、]*)?/gi; const bareDomainCandidateRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{2,5})?(?:\/[^\s<,。!?;:、】【)》」』、?#]*)*(?:\?[^\s<,。!?;:、】【)》」』、#]*)?(?:#[^\s<,。!?;:、】【)》」』、]*)?$/i; const trailingPunctuationRegex = /[),.!?\]}'",。!?;:、】【)》」』、]+$/; const skipTags = new Set(['A', 'CODE', 'PRE', 'SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'SELECT', 'BUTTON']); const shouldSkipTextNode = (node) => { const parent = node.parentElement; if (!parent) return true; if (parent.closest('a, code, pre, script, style, textarea, input, select, button')) return true; return skipTags.has(parent.tagName); }; const normalizeHref = (value) => { if (/^https?:\/\//i.test(value)) return value; return `https://${value}`; }; const hasTightBoundary = (char) => !!char && /[0-9A-Za-z]/.test(char); const extractCandidate = (text, match, type) => { let matchedText = match[0]; while (trailingPunctuationRegex.test(matchedText)) { matchedText = matchedText.replace(trailingPunctuationRegex, ''); } if (!matchedText) return null; const startIndex = match.index; const endIndex = startIndex + matchedText.length; const prevChar = text[startIndex - 1] || ''; const nextChar = text[endIndex] || ''; const isBareDomain = type === 'bare'; if (type === 'www' && hasTightBoundary(prevChar)) { return null; } if (type === 'bare' && hasTightBoundary(prevChar)) { return null; } if (hasTightBoundary(nextChar)) { return null; } if (isBareDomain) { if (!bareDomainCandidateRegex.test(matchedText)) return null; } return { text: matchedText, href: normalizeHref(matchedText), startIndex, endIndex }; }; const linkifyTextNode = (textNode) => { const text = textNode.nodeValue || ''; if (!text.trim()) return; protocolUrlRegex.lastIndex = 0; wwwUrlRegex.lastIndex = 0; bareDomainScanRegex.lastIndex = 0; const matches = [ ...Array.from(text.matchAll(protocolUrlRegex)).map(match => ({ match, type: 'protocol' })), ...Array.from(text.matchAll(wwwUrlRegex)).map(match => ({ match, type: 'www' })), ...Array.from(text.matchAll(bareDomainScanRegex)).map(match => ({ match, type: 'bare' })) ].sort((a, b) => a.match.index - b.match.index || b.match[0].length - a.match[0].length); if (matches.length === 0) return; const fragment = document.createDocumentFragment(); let cursor = 0; let changed = false; matches.forEach(({ match, type }) => { const candidate = extractCandidate(text, match, type); if (!candidate) return; if (candidate.startIndex < cursor) return; if (candidate.startIndex > cursor) { fragment.appendChild(document.createTextNode(text.slice(cursor, candidate.startIndex))); } const anchor = document.createElement('a'); anchor.href = candidate.href; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; anchor.textContent = candidate.text; anchor.style.color = '#007bff'; anchor.style.textDecoration = 'none'; anchor.addEventListener('mouseenter', () => { anchor.style.textDecoration = 'underline'; anchor.style.textDecorationColor = '#007bff'; }); anchor.addEventListener('mouseleave', () => { anchor.style.textDecoration = 'none'; }); fragment.appendChild(anchor); cursor = candidate.endIndex; changed = true; }); if (!changed) return; if (cursor < text.length) { fragment.appendChild(document.createTextNode(text.slice(cursor))); } textNode.parentNode.replaceChild(fragment, textNode); }; const processContainer = (container) => { if (!container) return; const pendingWalker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; if (shouldSkipTextNode(node)) return NodeFilter.FILTER_REJECT; protocolUrlRegex.lastIndex = 0; if (protocolUrlRegex.test(node.nodeValue)) return NodeFilter.FILTER_ACCEPT; wwwUrlRegex.lastIndex = 0; if (wwwUrlRegex.test(node.nodeValue)) return NodeFilter.FILTER_ACCEPT; const bareMatches = Array.from(node.nodeValue.matchAll(bareDomainScanRegex)); return bareMatches.some(({ 0: value, index }) => extractCandidate(node.nodeValue, { 0: value, index }, 'bare')) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } } ); const hasPendingText = !!pendingWalker.nextNode(); if (container.getAttribute(processedAttr) === '1' && !hasPendingText) return; const walker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; if (shouldSkipTextNode(node)) return NodeFilter.FILTER_REJECT; protocolUrlRegex.lastIndex = 0; if (protocolUrlRegex.test(node.nodeValue)) return NodeFilter.FILTER_ACCEPT; wwwUrlRegex.lastIndex = 0; if (wwwUrlRegex.test(node.nodeValue)) return NodeFilter.FILTER_ACCEPT; const bareMatches = Array.from(node.nodeValue.matchAll(bareDomainScanRegex)); return bareMatches.some(({ 0: value, index }) => extractCandidate(node.nodeValue, { 0: value, index }, 'bare')) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } } ); const nodes = []; let currentNode; while ((currentNode = walker.nextNode())) { nodes.push(currentNode); } nodes.forEach(linkifyTextNode); container.setAttribute(processedAttr, '1'); }; const containers = new Set(); if (scope.matches && scope.matches(containerSelector)) { containers.add(scope); } if (scope.closest) { const closestContainer = scope.closest(containerSelector); if (closestContainer) containers.add(closestContainer); } if (scope.querySelectorAll) { scope.querySelectorAll(containerSelector).forEach(node => containers.add(node)); } containers.forEach(processContainer); }, () => startupPerfDebug.summarizeRoot(root || document)); } /* -------------------------------------------------- * tag 22. 检查更新 * -------------------------------------------------- */ const UPDATE_CHECK_KEY = 'xdex_update_check_state'; const UPDATE_EXTENSION_CHECK_KEY = 'xdex_extension_update_check_state'; const UPDATE_GREASYFORK_META_URL = 'https://update.greasyfork.org/scripts/531005/X%E5%B2%9B-EX.meta.js'; const UPDATE_SCRIPTCAT_API_URL = 'https://scriptcat.org/api/v2/scripts/6289'; const UPDATE_EXTENSION_GITHUB_JSON_URL = 'https://raw.githubusercontent.com/SayaGoodBye/nmbxd-EX/main/nmbxd-EX-Extension/update.json'; const UPDATE_EXTENSION_JSDELIVR_JSON_URL = 'https://fastly.jsdelivr.net/gh/SayaGoodBye/nmbxd-EX@main/nmbxd-EX-Extension/update.json'; const UPDATE_CHECK_HOUR = 11; function normalizeMetaChangelog(text) { return String(text || '') .replace(/\\r\\n/g, '\n') .replace(/\\n/g, '\n') .replace(/\\r/g, '\n') .trim(); } function parseVersionAndChangelogFromMeta(metaText) { const text = String(metaText || ''); const versionMatch = text.match(/^\/\/\s*@version\s+(.+)$/m); const changelogMatches = [...text.matchAll(/^\/\/\s*@changelog\s+(.+)$/gm)]; const changelog = normalizeMetaChangelog(changelogMatches .map(m => String(m[1] || '').trim()) .filter(Boolean) .join('\n') .trim()); return { version: versionMatch ? String(versionMatch[1] || '').trim() : '', changelog }; } const CHANGELOG = parseVersionAndChangelogFromMeta(GM_info.scriptMetaStr || '').changelog || ''; function getUpdateCheckStorageKey() { return XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension' ? UPDATE_EXTENSION_CHECK_KEY : UPDATE_CHECK_KEY; } function getDefaultUpdateCheckState() { return { lastCheckDate: '', nextCheckAt: 0, pendingUpdateVersion: '', pendingUpdateChangelog: '', pendingUpdateSource: '', pendingUpdateDetectedAt: 0, latestRemoteVersion: '', ignoredVersion: '', lastDismissDate: '', dismissedUntil: 0 }; } function getUpdateCheckState() { try { const saved = GM_getValue(getUpdateCheckStorageKey(), null); const merged = Object.assign(getDefaultUpdateCheckState(), saved || {}); console.log('[update-check] get state:', merged); return merged; } catch (e) { console.warn('[update-check] get state failed, fallback to default:', e); return getDefaultUpdateCheckState(); } } function setUpdateCheckState(nextState) { const merged = Object.assign(getDefaultUpdateCheckState(), nextState || {}); GM_setValue(getUpdateCheckStorageKey(), merged); console.log('[update-check] set state:', merged); return merged; } function formatLocalDateKey(ts = Date.now()) { const d = new Date(ts); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function getNextNaturalCheckAt(nowTs = Date.now(), hour = UPDATE_CHECK_HOUR) { const d = new Date(nowTs); d.setDate(d.getDate() + 1); d.setHours(hour, 0, 0, 0); return d.getTime(); } function compareVersionStrings(a, b) { const pa = String(a || '').split('.').map(v => parseInt(v, 10) || 0); const pb = String(b || '').split('.').map(v => parseInt(v, 10) || 0); const len = Math.max(pa.length, pb.length); for (let i = 0; i < len; i++) { const av = pa[i] || 0; const bv = pb[i] || 0; if (av > bv) return 1; if (av < bv) return -1; } return 0; } async function fetchMetaVersionAndChangelog(url, source) { const resp = await gmRequest(url, 'text'); const parsed = parseVersionAndChangelogFromMeta(resp.responseText || ''); return { source, url, version: parsed.version, changelog: parsed.changelog }; } async function fetchScriptCatVersionAndChangelog(url, source = 'scriptcat') { const resp = await gmRequest(url, 'text'); const json = JSON.parse(resp.responseText || '{}'); const script = (json && json.data && json.data.script) || {}; return { source, url, version: String(script.version || '').trim(), changelog: String(script.changelog || '').trim() }; } async function fetchExtensionUpdateJson(url, source) { const resp = await gmRequest(url, 'text'); const json = JSON.parse(resp.responseText || '{}'); const extension = json && json.extension ? json.extension : json; return { source, url, version: String(extension.version || '').trim(), changelog: String(extension.changelog || '').trim(), downloads: extension.downloads || {} }; } function getUpdateCheckRequestsForRuntime() { if (XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension') { return [ () => fetchExtensionUpdateJson(UPDATE_EXTENSION_GITHUB_JSON_URL, 'github'), () => fetchExtensionUpdateJson(UPDATE_EXTENSION_JSDELIVR_JSON_URL, 'jsdelivr') ]; } return [ () => fetchMetaVersionAndChangelog(UPDATE_GREASYFORK_META_URL, 'greasyfork'), () => fetchScriptCatVersionAndChangelog(UPDATE_SCRIPTCAT_API_URL, 'scriptcat') ]; } function choosePreferredRemoteMeta(results) { const valid = (results || []).filter(item => item && item.version); if (!valid.length) return null; valid.sort((a, b) => compareVersionStrings(b.version, a.version)); const topVersion = valid[0].version; const topCandidates = valid.filter(item => compareVersionStrings(item.version, topVersion) === 0); const preferredSource = XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension' ? 'github' : 'greasyfork'; const preferred = topCandidates.find(item => item.source === preferredSource) || topCandidates[0]; return preferred; } function shouldShowPendingUpdateReminder(state, currentVersion = VERSION) { if (!state || !state.pendingUpdateVersion) return false; if (compareVersionStrings(state.pendingUpdateVersion, currentVersion) <= 0) return false; if (state.ignoredVersion && state.ignoredVersion === state.pendingUpdateVersion) return false; if (state.lastDismissDate && state.lastDismissDate === formatLocalDateKey()) return false; if (state.dismissedUntil && Date.now() < state.dismissedUntil) return false; return true; } function updateSettingsButtonBadge(state = getUpdateCheckState()) { const $btn = $('#sp_btn'); if (!$btn.length) return; if (typeof isUpdateCheckEnabled === 'function' && !isUpdateCheckEnabled()) { $btn.removeClass('xdex-has-update'); return; } $btn.toggleClass('xdex-has-update', shouldShowPendingUpdateReminder(state)); } function clearFooterUpdateHighlight() { const $links = $('#sp_panel_footer .sp_panel_links'); $links.removeClass('xdex-update-highlight xdex-update-source-greasyfork xdex-update-source-scriptcat xdex-update-source-github'); $links.find('[data-update-channel]').removeClass('xdex-update-link-primary xdex-update-link-secondary'); } function flashFooterUpdateHighlight(source = '') { const $links = $('#sp_panel_footer .sp_panel_links'); if (!$links.length) return; clearFooterUpdateHighlight(); $links.addClass('xdex-update-highlight'); const sourceKey = String(source || '').trim().toLowerCase(); const channelMap = { greasyfork: { containerClass: 'xdex-update-source-greasyfork', primary: 'greasyfork', secondary: ['github'] }, scriptcat: { containerClass: 'xdex-update-source-scriptcat', primary: 'scriptcat', secondary: ['baidupan'] }, github: { containerClass: 'xdex-update-source-github', primary: 'github', secondary: ['baidupan'] }, jsdelivr: { containerClass: 'xdex-update-source-github', primary: 'baidupan', secondary: ['github'] } }; const config = channelMap[sourceKey]; if (config) { $links.addClass(config.containerClass); $links.find(`[data-update-channel="${config.primary}"]`).addClass('xdex-update-link-primary'); (config.secondary || []).forEach((channel) => { $links.find(`[data-update-channel="${channel}"]`).addClass('xdex-update-link-secondary'); }); } setTimeout(() => { clearFooterUpdateHighlight(); }, 5000); } function renderUpdateLogDialog(mode = 'local', state = getUpdateCheckState()) { const $dlg = $('#sp_update_log'); if (!$dlg.length) return; const isRemote = mode === 'remote' && state && state.pendingUpdateVersion && compareVersionStrings(state.pendingUpdateVersion, VERSION) > 0; const title = isRemote ? `发现新版本 v${state.pendingUpdateVersion}` : '更新日志'; const bodyText = isRemote ? (state.pendingUpdateChangelog || `发现新版本 v${state.pendingUpdateVersion},但未提取到更新说明。`) : (CHANGELOG || '暂无更新说明'); $dlg.attr('data-update-mode', isRemote ? 'remote' : 'local'); $dlg.find('.xdex-update-log-title').text(title); $dlg.find('.xdex-update-log-body').text(bodyText); $dlg.find('.xdex-update-log-actions').css('display', isRemote ? 'flex' : 'none'); } function openUpdateLogDialog(mode = 'local') { const state = getUpdateCheckState(); renderUpdateLogDialog(mode, state); $('#sp_update_log').fadeIn(120); } function closeUpdateLogDialog(options = {}) { const { treatAsDismiss = false, reason = 'unknown' } = options || {}; const mode = $('#sp_update_log').attr('data-update-mode') || ''; if (treatAsDismiss) { const state = getUpdateCheckState(); state.lastDismissDate = formatLocalDateKey(); state.dismissedUntil = state.nextCheckAt || getNextNaturalCheckAt(); setUpdateCheckState(state); updateSettingsButtonBadge(state); console.log('[update-check] dismiss dialog:', { reason, mode, treatAsDismiss: true, lastDismissDate: state.lastDismissDate, dismissedUntil: state.dismissedUntil, dismissedUntilISO: state.dismissedUntil ? new Date(state.dismissedUntil).toISOString() : '' }); } else { console.log('[update-check] close dialog:', { reason, mode, treatAsDismiss: false }); } $('#sp_update_log').fadeOut(120); } function maybeShowPendingUpdateDialogOnPanelOpen() { if (!isUpdateCheckEnabled()) { const state = getDefaultUpdateCheckState(); updateSettingsButtonBadge(state); clearFooterUpdateHighlight(); return; } const state = getUpdateCheckState(); updateSettingsButtonBadge(state); if (shouldShowPendingUpdateReminder(state)) { openUpdateLogDialog('remote'); } } async function checkForDailyScriptUpdate(force = false) { if (!isUpdateCheckEnabled()) { const state = getUpdateCheckState(); updateSettingsButtonBadge(state); clearFooterUpdateHighlight(); return state; } const now = Date.now(); const today = formatLocalDateKey(now); const state = getUpdateCheckState(); const alreadyChecked = !force && state.nextCheckAt && now < state.nextCheckAt; console.log('[update-check] start:', { force, now, today, currentVersion: VERSION, state: Object.assign({}, state), alreadyChecked, nextCheckAtISO: state.nextCheckAt ? new Date(state.nextCheckAt).toISOString() : '' }); if (alreadyChecked) { console.log('[update-check] skip: already checked for current window', { force, now, nextCheckAt: state.nextCheckAt, nextCheckAtISO: new Date(state.nextCheckAt).toISOString() }); updateSettingsButtonBadge(state); return state; } state.lastCheckDate = today; state.nextCheckAt = getNextNaturalCheckAt(now); console.log('[update-check] scheduled next check:', { lastCheckDate: state.lastCheckDate, nextCheckAt: state.nextCheckAt, nextCheckAtISO: new Date(state.nextCheckAt).toISOString() }); try { const requests = getUpdateCheckRequestsForRuntime(); const settled = await Promise.allSettled(requests.map((request) => request())); const sourceResults = settled.map((item, index) => { const source = XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension' ? (index === 0 ? 'github' : 'jsdelivr') : (index === 0 ? 'greasyfork' : 'scriptcat'); if (item.status === 'fulfilled') { console.log('[update-check] remote meta success:', item.value); return item.value; } console.warn(`[update-check] remote meta failed: ${source}`, item.reason); return null; }); const preferredRemote = choosePreferredRemoteMeta(sourceResults); console.log('[update-check] remote meta choice:', { localVersion: VERSION, candidates: sourceResults, preferred: preferredRemote }); const remoteVersion = preferredRemote ? String(preferredRemote.version || '').trim() : ''; const remoteChangelog = preferredRemote ? String(preferredRemote.changelog || '').trim() : ''; state.latestRemoteVersion = remoteVersion; if (remoteVersion && compareVersionStrings(remoteVersion, VERSION) > 0) { state.pendingUpdateVersion = remoteVersion; state.pendingUpdateDetectedAt = now; state.pendingUpdateChangelog = remoteChangelog || `发现新版本 v${remoteVersion},但未提取到更新说明。`; state.pendingUpdateSource = preferredRemote ? String(preferredRemote.source || '').trim() : ''; if (state.dismissedUntil && now >= state.dismissedUntil) { state.dismissedUntil = 0; state.lastDismissDate = ''; } if (state.ignoredVersion && compareVersionStrings(state.ignoredVersion, remoteVersion) < 0) { state.ignoredVersion = ''; } } else { state.pendingUpdateVersion = ''; state.pendingUpdateChangelog = ''; state.pendingUpdateSource = ''; state.pendingUpdateDetectedAt = 0; } } catch (e) { console.warn('[update-check] daily update check failed:', e); } console.log('[update-check] final state before save:', state); setUpdateCheckState(state); updateSettingsButtonBadge(state); return state; } function initializeUpdateReminderUI() { updateSettingsButtonBadge(getUpdateCheckState()); clearFooterUpdateHighlight(); } window.__xdexGetUpdateCheckState = function() { return getUpdateCheckState(); }; window.__xdexCheckUpdateNow = function() { return checkForDailyScriptUpdate(true).then((state) => { scheduleNextUpdateCheckTimer(state); return state; }); }; window.__xdexClearUpdateCheckState = function() { GM_deleteValue(UPDATE_CHECK_KEY); const state = getDefaultUpdateCheckState(); updateSettingsButtonBadge(state); clearFooterUpdateHighlight(); console.log('[update-check] cleared state'); return state; }; window.__xdexSetUpdateCheckState = function(nextState) { const state = setUpdateCheckState(nextState || {}); updateSettingsButtonBadge(state); scheduleNextUpdateCheckTimer(state); return state; }; window.__xdexPatchUpdateCheckState = function(patch) { const state = Object.assign(getUpdateCheckState(), patch || {}); return window.__xdexSetUpdateCheckState(state); }; const updateDebugTarget = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; updateDebugTarget.__xdexGetUpdateCheckState = window.__xdexGetUpdateCheckState; updateDebugTarget.__xdexCheckUpdateNow = window.__xdexCheckUpdateNow; updateDebugTarget.__xdexClearUpdateCheckState = window.__xdexClearUpdateCheckState; updateDebugTarget.__xdexSetUpdateCheckState = window.__xdexSetUpdateCheckState; updateDebugTarget.__xdexPatchUpdateCheckState = window.__xdexPatchUpdateCheckState; function scheduleDailyUpdateCheck() { if (!isUpdateCheckEnabled()) { scheduleNextUpdateCheckTimer(getUpdateCheckState()); return; } checkForDailyScriptUpdate(false) .then((state) => { scheduleNextUpdateCheckTimer(state); if (shouldShowPendingUpdateReminder(state) && $('#sp_cover').is(':visible')) { openUpdateLogDialog('remote'); } }) .catch((e) => { console.warn('[update-check] schedule failed:', e); }); } function scheduleNextUpdateCheckTimer(state = getUpdateCheckState()) { if (window.__xdexUpdateCheckTimer) { clearTimeout(window.__xdexUpdateCheckTimer); window.__xdexUpdateCheckTimer = 0; } if (!isUpdateCheckEnabled()) return; const nextCheckAt = Number(state && state.nextCheckAt || 0); if (!nextCheckAt) return; const delay = Math.max(0, nextCheckAt - Date.now()); window.__xdexUpdateCheckTimer = setTimeout(() => { window.__xdexUpdateCheckTimer = 0; scheduleDailyUpdateCheck(); }, Math.min(delay, 2147483647)); console.log('[update-check] timer scheduled:', { nextCheckAt, nextCheckAtISO: new Date(nextCheckAt).toISOString(), delay }); } /* -------------------------------------------------- * tag 23. 图片/GIF 右键菜单增强 * -------------------------------------------------- */ function ensureImageContextMenuStyle() { if (document.getElementById('xdex-image-context-menu-style')) return; const style = document.createElement('style'); style.id = 'xdex-image-context-menu-style'; style.textContent = ` #xdex-image-context-menu { position: fixed; z-index: 10050; min-width: 156px; max-width: 220px; padding: 6px 0; background: #FFFFEE; border: 1px solid #d6c7ba; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.18); font-size: 13px; color: #333; user-select: none; } :root.xdex-darkreader-active #xdex-image-context-menu { background: #2b2c2d; border-color: #4b4d50; box-shadow: 0 10px 28px rgba(0,0,0,0.5); color: #e6e6e6; } #xdex-image-context-menu.xdex-hidden { display: none; } #xdex-image-context-menu .xdex-image-context-item { display: flex; align-items: center; width: 100%; padding: 8px 12px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; line-height: 1.35; box-sizing: border-box; } #xdex-image-context-menu .xdex-image-context-item:hover, #xdex-image-context-menu .xdex-image-context-item:focus { background: rgba(102, 204, 255, 0.14); outline: none; } :root.xdex-darkreader-active #xdex-image-context-menu .xdex-image-context-item:hover, :root.xdex-darkreader-active #xdex-image-context-menu .xdex-image-context-item:focus { background: rgba(102, 204, 255, 0.18); } #xdex-image-context-menu .xdex-image-context-sep { height: 1px; margin: 4px 0; background: rgba(0,0,0,0.08); } :root.xdex-darkreader-active #xdex-image-context-menu .xdex-image-context-sep { background: rgba(255,255,255,0.08); } #xdex-image-context-menu .xdex-image-context-hint { display: block; margin-top: 2px; font-size: 11px; opacity: 0.72; } `; document.head.appendChild(style); } function gmRequestBlob(url) { return new Promise((resolve, reject) => { if (!url || typeof GM_xmlhttpRequest !== 'function') { reject(new Error('GM_xmlhttpRequest 不可用')); return; } GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', onload: (res) => { if (res.status >= 200 && res.status < 300 && res.response) { resolve(res.response); } else { reject(new Error(`请求失败: ${res.status} ${url}`)); } }, onerror: (err) => { const reason = err && (err.error || err.statusText || err.status || err.message); reject(new Error(`请求失败: ${reason || '未知错误'} ${url}`)); }, ontimeout: () => reject(new Error('请求超时')) }); }); } async function fetchImageBlobByBrowser(url) { const resp = await fetch(url, { credentials: 'omit', cache: 'no-store' }); if (!resp.ok) throw new Error(`fetch 请求失败: ${resp.status} ${url}`); return await resp.blob(); } function handleImageContextMenuEvent(e, layer) { if (layer === 'document' && e.defaultPrevented) return false; // console.info('【右键菜单|收到右键】', { // 标签: e.target && e.target.tagName, // 类名: e.target && e.target.className ? String(e.target.className).slice(0, 120) : '', // 坐标: `${e.clientX},${e.clientY}`, // 层级: layer // }); const anchor = e.target && e.target.closest ? e.target.closest('.h-threads-img-a') : null; if (!anchor) { // console.info('【右键菜单|跳过】', { 原因: '未命中图片链接', 层级: layer }); return false; } if (anchor.closest('.h-preview-box')) { // console.info('【右键菜单|跳过】', { 原因: '命中预览框图片', 层级: layer }); return false; } // console.info('【右键菜单|命中】', { 链接: (anchor.href || '').slice(0, 200), 层级: layer }); const ctx = resolveImageContextData(anchor); if (!ctx) { console.warn('【右键菜单|解析失败】', { 原因: '图片上下文为空', 层级: layer }); return false; } e.preventDefault(); if (typeof e.stopImmediatePropagation === 'function') { e.stopImmediatePropagation(); } e.stopPropagation(); e.cancelBubble = true; e.returnValue = false; // console.info('【右键菜单|阻止原生】', { 成功: true, 层级: layer }); openImageContextMenu(ctx, e.clientX, e.clientY); return true; } function getImageContextMenu() { ensureImageContextMenuStyle(); let menu = document.getElementById('xdex-image-context-menu'); // if (menu) console.debug('【右键菜单|复用】', { 已存在: true }); if (menu) return menu; // console.debug('【右键菜单|创建】', { 已存在: false }); menu = document.createElement('div'); menu.id = 'xdex-image-context-menu'; menu.className = 'xdex-hidden'; menu.innerHTML = [ // '<button type="button" class="xdex-image-context-item" data-action="copy-image">复制图片<span class="xdex-image-context-hint">GIF 先尝试原样复制,失败时自动降级</span></button>', '<button type="button" class="xdex-image-context-item" data-action="copy-image">复制图片</button>', '<button type="button" class="xdex-image-context-item" data-action="download-image">下载图片</button>', '<button type="button" class="xdex-image-context-item" data-action="copy-image-url">复制图片链接</button>', // '<div class="xdex-image-context-sep"></div>', '<button type="button" class="xdex-image-context-item" data-action="copy-thread-link">复制串链接</button>' ].join(''); menu.__context = null; menu.addEventListener('click', async (e) => { const btn = e.target.closest('.xdex-image-context-item[data-action]'); if (!btn) return; e.preventDefault(); const ctx = menu.__context; closeImageContextMenu(); if (!ctx) return; const action = btn.getAttribute('data-action'); try { if (action === 'copy-image') { await copyImageBinary(ctx); } else if (action === 'download-image') { await downloadImageBlob(ctx); } else if (action === 'copy-image-url') { await copyImageUrl(ctx); } else if (action === 'copy-thread-link') { await copyThreadLink(ctx); } } catch (err) { console.warn('[tag23] action failed:', action, err); toast('操作失败,请重试'); } }); document.body.appendChild(menu); // console.info('【右键菜单|挂载】', { 位置: 'body', 菜单项数: menu.querySelectorAll('.xdex-image-context-item').length }); if (!getImageContextMenu.__globalBound) { // console.info('【右键菜单|注册】', { 类型: 'contextmenu/click/blur/scroll/keydown', 阶段: 'capture' }); window.addEventListener('contextmenu', (e) => { return handleImageContextMenuEvent(e, 'window') ? false : undefined; }, true); document.addEventListener('contextmenu', (e) => { return handleImageContextMenuEvent(e, 'document') ? false : undefined; }, true); document.addEventListener('click', (e) => { const cur = document.getElementById('xdex-image-context-menu'); if (!cur || cur.classList.contains('xdex-hidden')) return; if (cur.contains(e.target)) return; closeImageContextMenu(); }, true); window.addEventListener('blur', () => closeImageContextMenu()); window.addEventListener('scroll', () => closeImageContextMenu(), true); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeImageContextMenu(); }); getImageContextMenu.__globalBound = true; } return menu; } function closeImageContextMenu() { const menu = document.getElementById('xdex-image-context-menu'); if (!menu) return; menu.classList.add('xdex-hidden'); menu.style.left = '-9999px'; menu.style.top = '-9999px'; menu.__context = null; // console.debug('【右键菜单|关闭】', { 原因: '隐藏菜单' }); } function shouldUseExtensionNativeImageContextMenu(cfg) { return !!(XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension' && cfg && cfg.enableImageContextMenu); } function openImageContextMenu(context, x, y) { const menu = getImageContextMenu(); menu.__context = context; menu.classList.remove('xdex-hidden'); menu.style.left = '0px'; menu.style.top = '0px'; const rect = menu.getBoundingClientRect(); const left = Math.min(x, window.innerWidth - rect.width - 8); const top = Math.min(y, window.innerHeight - rect.height - 8); menu.style.left = Math.max(8, left) + 'px'; menu.style.top = Math.max(8, top) + 'px'; // console.info('【右键菜单|显示】', { // 位置: `${menu.style.left},${menu.style.top}`, // 图片地址: String(context && context.imageUrl || '').slice(0, 200), // GIF: !!(context && context.isGif) // }); } function resolveThreadLinkFromImageTarget(target) { const $target = $(target); const tid = getThreadIdForElement($target); if (tid) return `${location.origin}/t/${tid}`; return location.href; } function sanitizeImageFileName(name) { return String(name || 'image') .replace(/[\\/:*?"<>|]+/g, '_') .replace(/\s+/g, ' ') .trim() || 'image'; } function resolveImageContextData(anchorOrBox) { const anchor = anchorOrBox && anchorOrBox.matches && anchorOrBox.matches('.h-threads-img-a') ? anchorOrBox : anchorOrBox && anchorOrBox.querySelector ? anchorOrBox.querySelector('.h-threads-img-a') : null; if (!anchor) return null; const imgBox = anchor.closest('.h-threads-img-box'); const img = imgBox ? imgBox.querySelector('.h-threads-img') : null; const rawUrl = (anchor.href || (img && (img.currentSrc || img.src)) || '').trim(); if (!rawUrl) { console.warn('【右键菜单|解析跳过】', { 原因: '图片地址为空' }); return null; } const cleanUrl = rawUrl.replace('/thumb/', '/image/'); let fileName = ''; try { const u = new URL(cleanUrl, location.href); const leaf = decodeURIComponent((u.pathname.split('/').pop() || '').trim()); fileName = sanitizeImageFileName(leaf || `image-${Date.now()}`); } catch (e) { fileName = sanitizeImageFileName(`image-${Date.now()}`); } return { anchor, imgBox, img, imageUrl: cleanUrl, imageUrlRaw: rawUrl, threadUrl: resolveThreadLinkFromImageTarget(anchor), fileName, isGif: /\.gif(?:$|[?#])/i.test(cleanUrl) }; } async function writeClipboardText(text, successToast) { await navigator.clipboard.writeText(String(text || '')); if (successToast) toast(successToast); } function blobToObjectUrlDownload(blob, fileName) { const objectUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; a.download = sanitizeImageFileName(fileName); document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(objectUrl), 3000); } function resolveImageMimeType(ctx, blob) { let type = String(blob && blob.type || '').split(';')[0].trim().toLowerCase(); if (type === 'image/jpg') type = 'image/jpeg'; if (type) return type; const source = `${ctx && ctx.imageUrl || ''} ${ctx && ctx.fileName || ''}`; if (/\.gif(?:$|[?#\s])/i.test(source)) return 'image/gif'; if (/\.jpe?g(?:$|[?#\s])/i.test(source)) return 'image/jpeg'; if (/\.png(?:$|[?#\s])/i.test(source)) return 'image/png'; if (/\.webp(?:$|[?#\s])/i.test(source)) return 'image/webp'; if (/\.bmp(?:$|[?#\s])/i.test(source)) return 'image/bmp'; if (/\.avif(?:$|[?#\s])/i.test(source)) return 'image/avif'; return ''; } async function detectImageMimeTypeFromMagic(blob) { if (!blob || blob.size < 4) return ''; const bytes = new Uint8Array(await blob.slice(0, 16).arrayBuffer()); if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'image/gif'; if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return 'image/png'; if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return 'image/webp'; if (bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp'; return ''; } async function fetchImageBlobForContext(ctx) { let blob; try { blob = await gmRequestBlob(ctx.imageUrl); } catch (err) { console.warn('【右键菜单|失败】', { 动作: '抓取图片', 阶段: 'GM_xmlhttpRequest', 原因: err && err.message ? err.message : String(err) }); blob = await fetchImageBlobByBrowser(ctx.imageUrl); console.info('【右键菜单|抓取图片】', { 阶段: 'fetch备用成功', 地址: String(ctx && ctx.imageUrl || '').slice(0, 200), MIME: String(blob && blob.type || ''), 大小: blob && blob.size }); } const type = resolveImageMimeType(ctx, blob); if (!blob || !type.startsWith('image/')) { throw new Error(`Not an image content type. Got: ${blob && blob.type}`); } return blob; } async function convertBlobToPng(blob) { const bitmap = await createImageBitmap(blob); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); bitmap.close(); return await new Promise((resolve, reject) => { canvas.toBlob((pngBlob) => { if (pngBlob) resolve(pngBlob); else reject(new Error('Canvas toBlob failed')); }, 'image/png'); }); } function createClipboardBlob(data, type) { return new Blob([data], { type }); } function escapeHtmlAttr(value) { return String(value || '').replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]); } function escapeHtml(value) { return String(value || '').replace(/[&<>]/g, (ch) => ({ '&': '&', '<': '<', '>': '>' })[ch]); } async function isAnimatedPngBlob(blob) { if (!blob || blob.size < 33) return false; const header = new Uint8Array(await blob.slice(0, Math.min(blob.size, 1024 * 1024)).arrayBuffer()); const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]; for (let i = 0; i < pngSignature.length; i++) { if (header[i] !== pngSignature[i]) return false; } for (let offset = 8; offset + 12 <= header.length;) { const length = ((header[offset] << 24) | (header[offset + 1] << 16) | (header[offset + 2] << 8) | header[offset + 3]) >>> 0; const type = String.fromCharCode(header[offset + 4], header[offset + 5], header[offset + 6], header[offset + 7]); if (type === 'acTL') return true; if (type === 'IDAT' || type === 'IEND') return false; offset += 12 + length; if (offset > header.length) return false; } return false; } async function copyAnimatedPngBinary(ctx, blob) { const imageUrl = String(ctx && ctx.imageUrl || ''); const pngBlob = blob.type === 'image/png' ? blob : new Blob([blob], { type: 'image/png' }); const webPngSupports = typeof ClipboardItem.supports === 'function' ? ClipboardItem.supports('web image/png') : undefined; console.info('【右键菜单|APNG复制】', { 阶段: '开始', 大小: pngBlob.size, 原始MIME: String(blob.type || ''), webImagePngSupports: webPngSupports, 地址: imageUrl.slice(0, 200) }); try { const html = `<img src="${escapeHtmlAttr(imageUrl)}">`; await navigator.clipboard.write([new ClipboardItem({ 'web image/png': pngBlob, 'text/html': createClipboardBlob(html, 'text/html'), 'text/plain': createClipboardBlob(imageUrl, 'text/plain') })]); toast('APNG 已使用富文本图片格式复制到剪贴板'); console.info('【右键菜单|APNG复制】', { 阶段: 'web image/png成功', 附带: 'text/html,text/plain' }); return true; } catch (err) { console.warn('【右键菜单|失败】', { 动作: '复制APNG', 阶段: 'web image/png写入', 原因: err && err.message ? err.message : String(err) }); } try { const html = `<img src="${escapeHtmlAttr(imageUrl)}">`; await navigator.clipboard.write([new ClipboardItem({ 'text/html': createClipboardBlob(html, 'text/html'), 'text/plain': createClipboardBlob(imageUrl, 'text/plain') })]); toast('APNG 已使用富文本图片格式复制到剪贴板'); console.info('【右键菜单|APNG复制】', { 阶段: 'text/html成功' }); return true; } catch (err) { console.warn('【右键菜单|失败】', { 动作: '复制APNG', 阶段: 'text/html写入', 原因: err && err.message ? err.message : String(err) }); } await copyImageUrl(ctx); toast('当前环境无法保留 APNG 动画,已复制图片地址'); console.info('【右键菜单|APNG复制】', { 阶段: 'URL兜底' }); return false; } async function copyGifBinary(ctx, blob) { const gifUrl = String(ctx && ctx.imageUrl || ''); const gifBlob = blob.type === 'image/gif' ? blob : new Blob([blob], { type: 'image/gif' }); const gifSupports = typeof ClipboardItem.supports === 'function' ? ClipboardItem.supports('image/gif') : undefined; const webGifSupports = typeof ClipboardItem.supports === 'function' ? ClipboardItem.supports('web image/gif') : undefined; console.info('【右键菜单|GIF复制】', { 阶段: '开始', 大小: gifBlob.size, 原始MIME: String(blob.type || ''), imageGifSupports: gifSupports, webImageGifSupports: webGifSupports, 地址: gifUrl.slice(0, 200) }); if (gifSupports === true) { try { await navigator.clipboard.write([new ClipboardItem({ 'image/gif': gifBlob })]); toast('GIF 已原样复制到剪贴板'); console.info('【右键菜单|GIF复制】', { 阶段: 'image/gif成功' }); return true; } catch (err) { console.warn('【右键菜单|失败】', { 动作: '复制GIF', 阶段: 'image/gif写入', 原因: err && err.message ? err.message : String(err) }); } } else { console.info('【右键菜单|GIF复制】', { 阶段: 'image/gif跳过', 原因: 'ClipboardItem.supports 未确认支持' }); } try { const html = `<img src="${escapeHtmlAttr(gifUrl)}">`; const data = { 'web image/gif': gifBlob, 'text/html': createClipboardBlob(html, 'text/html'), 'text/plain': createClipboardBlob(gifUrl, 'text/plain') }; await navigator.clipboard.write([new ClipboardItem(data)]); toast('GIF 已使用富文本图片格式复制到剪贴板'); console.info('【右键菜单|GIF复制】', { 阶段: 'web image/gif成功', 附带: 'text/html,text/plain' }); return true; } catch (err) { console.warn('【右键菜单|失败】', { 动作: '复制GIF', 阶段: 'web image/gif写入', 原因: err && err.message ? err.message : String(err) }); } try { const html = `<img src="${escapeHtmlAttr(gifUrl)}">`; await navigator.clipboard.write([new ClipboardItem({ 'text/html': createClipboardBlob(html, 'text/html'), 'text/plain': createClipboardBlob(gifUrl, 'text/plain') })]); toast('GIF 已使用富文本图片格式复制到剪贴板'); console.info('【右键菜单|GIF复制】', { 阶段: 'text/html成功' }); return true; } catch (err) { console.warn('【右键菜单|失败】', { 动作: '复制GIF', 阶段: 'text/html写入', 原因: err && err.message ? err.message : String(err) }); } await copyImageUrl(ctx); toast('当前环境无法复制 GIF 二进制,已复制图片地址'); console.info('【右键菜单|GIF复制】', { 阶段: 'URL兜底' }); return false; } async function copyImageBinary(ctx) { console.info('【右键菜单|执行】', { 动作: '复制图片', GIF: !!(ctx && ctx.isGif), 地址: String(ctx && ctx.imageUrl || '').slice(0, 200) }); if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function' || typeof ClipboardItem === 'undefined') { await copyImageUrl(ctx); toast('当前环境不支持复制图片,已复制图片地址'); return; } const blob = await fetchImageBlobForContext(ctx); const declaredType = resolveImageMimeType(ctx, blob); const magicType = await detectImageMimeTypeFromMagic(blob); const type = magicType || declaredType; const supportsOriginal = type === 'image/png' || (type === 'image/svg+xml' && typeof ClipboardItem.supports === 'function' && ClipboardItem.supports(type)); console.info('【右键菜单|复制诊断】', { MIME: type, 原始MIME: String(blob.type || ''), 魔数MIME: magicType, 大小: blob.size, ClipboardItemSupports: supportsOriginal, GIF: !!(ctx && ctx.isGif) }); if (type === 'image/gif') { await copyGifBinary(ctx, blob); return; } if (type === 'image/png' && await isAnimatedPngBlob(blob)) { await copyAnimatedPngBinary(ctx, blob); return; } try { if (supportsOriginal) { await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]); toast('图片已复制到剪贴板'); return; } console.info('【右键菜单|复制诊断】', { 阶段: '原始格式跳过', MIME: type, 原因: '仅 PNG/SVG 作为可靠原格式写入' }); } catch (err) { console.warn('[tag23] native clipboard write failed:', type, err); console.warn('【右键菜单|失败】', { 动作: '复制图片', 阶段: '原始格式写入', 原因: err && err.message ? err.message : String(err) }); } if (type !== 'image/gif') { try { const pngBlob = await convertBlobToPng(blob); await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]); toast('已复制为 PNG '); return; } catch (err) { console.warn('[tag23] png fallback failed:', err); console.warn('【右键菜单|失败】', { 动作: '复制图片', 阶段: 'PNG降级', 原因: err && err.message ? err.message : String(err) }); } } await copyImageUrl(ctx); toast(type === 'image/gif' ? '当前环境无法直接复制 GIF,已复制图片地址' : '当前环境无法直接复制图片,已复制图片地址'); } async function copyImageUrl(ctx) { console.info('【右键菜单|执行】', { 动作: '复制图片地址', 地址: String(ctx && ctx.imageUrl || '').slice(0, 200) }); await writeClipboardText(ctx.imageUrl, '图片地址已复制'); } async function downloadImageBlob(ctx) { console.info('【右键菜单|执行】', { 动作: '下载图片', 地址: String(ctx && ctx.imageUrl || '').slice(0, 200), 文件名: ctx && ctx.fileName }); try { const blob = await fetchImageBlobForContext(ctx); let fileName = ctx.fileName; if (!/\.[a-z0-9]+$/i.test(fileName)) { const ext = (blob.type || '').split('/')[1] || 'img'; fileName = `${fileName}.${ext}`; } blobToObjectUrlDownload(blob, fileName); toast('图片开始下载'); } catch (err) { console.warn('[tag23] download image failed:', err); console.warn('【右键菜单|失败】', { 动作: '下载图片', 原因: err && err.message ? err.message : String(err) }); await copyImageUrl(ctx); toast('下载失败,已复制图片地址'); } } async function copyThreadLink(ctx) { console.info('【右键菜单|执行】', { 动作: '复制串链接', 串链接: String(ctx && ctx.threadUrl || '').slice(0, 200) }); await writeClipboardText(ctx.threadUrl, '串链接已复制'); } function enableImageContextMenu(root = document) { return startupPerfDebug.measure('enableImageContextMenu', () => { const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); if (!cfg.enableImageContextMenu) return; if (shouldUseExtensionNativeImageContextMenu(cfg)) return; ensureImageContextMenuStyle(); getImageContextMenu(); }, () => startupPerfDebug.summarizeRoot(root)); } /* -------------------------------------------------- * tag 24. 常用串/浏览历史/发言历史/移动端订阅 * -------------------------------------------------- */ function isValidThreadId(threadId) { return /^\d{6,8}$/.test(threadId); } function normalizeFavoriteThreadInput(raw) { const value = String(raw || '').trim(); if (isValidThreadId(value)) return value; let url; try { url = new URL(value, location.origin); } catch (e) { return ''; } if (url.hostname && !['www.nmbxd1.com', 'nmbxd1.com', 'www.nmbxd.com', 'nmbxd.com'].includes(url.hostname)) return ''; const path = url.pathname || ''; const threadMatch = path.match(/^\/t\/(\d{6,8})(?:\/\d+)?\/?$/); if (threadMatch) return threadMatch[1]; const poMatch = path.match(/^\/Forum\/po\/id\/(\d{6,8})(?:\/page\/\d+)?(?:\.html)?$/); if (poMatch) return poMatch[1]; return ''; } function makeFavoriteThreadUrl(threadId) { return `https://www.nmbxd1.com/t/${threadId}`; } function trimFavoriteThreadDesc(desc) { return String(desc || '').trim().slice(0, 20); } // 判断码点是否为全角/CJK字符(视觉宽度 2) function _isFullWidthCodePoint(cp) { return ( (cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo (cp >= 0x2E80 && cp <= 0x303E) || // CJK Radicals, Kangxi, etc. (cp >= 0x3040 && cp <= 0x33BF) || // Hiragana, Katakana, Bopomofo, etc. (cp >= 0x3400 && cp <= 0x4DBF) || // CJK Extension A (cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs (cp >= 0xA000 && cp <= 0xA4CF) || // Yi Syllables (cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables (cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs (cp >= 0xFE30 && cp <= 0xFE4F) || // CJK Compatibility Forms (cp >= 0xFF01 && cp <= 0xFF60) || // Fullwidth Forms(含全角字母/数字/标点) (cp >= 0xFFE0 && cp <= 0xFFE6) || // Fullwidth Signs (cp >= 0x20000 && cp <= 0x2FA1F) || // CJK Extensions B-F cp === 0x2026 // HORIZONTAL ELLIPSIS(东亚语境占全角宽度) ); } // 按视觉宽度截断(CJK/全角=2,其余=1) function _truncateByVisualWidth(str, maxWidth) { let w = 0; for (let i = 0; i < str.length; i++) { const cp = str.codePointAt(i); const cw = _isFullWidthCodePoint(cp) ? 2 : 1; if (w + cw > maxWidth) return str.slice(0, i); w += cw; if (cp > 0xFFFF) i++; // surrogate pair 跳过 } return str; } function _visualWidth(str) { let w = 0; for (const ch of str) w += _isFullWidthCodePoint(ch.codePointAt(0)) ? 2 : 1; return w; } function formatFavoriteThreadMenuText(item) { const text = item && item.desc ? item.desc : item && item.threadId ? item.threadId : ''; const VISUAL_MAX = 18; // 侧边栏实际可用:≈7个汉字 + "……" const ELLIPSIS_W = 4; // "……" 两个全角省略号 = 4个半角宽度 const w = _visualWidth(text); if (w <= VISUAL_MAX) return text; const truncated = _truncateByVisualWidth(text, VISUAL_MAX - ELLIPSIS_W); // 截断后加"……"反而比原文更宽(或差不多)→ 直接显示原文,接受轻微溢出 if (_visualWidth(truncated) + ELLIPSIS_W >= w) return text; return `${truncated}……`; } function normalizeFavoriteThreads(val) { if (!Array.isArray(val)) return []; const seen = new Set(); return val.map((item) => { const desc = item && typeof item.desc === 'string' && isValidDesc(item.desc.trim()) ? item.desc.trim() : ''; const threadId = normalizeFavoriteThreadInput(item && typeof item.threadId === 'string' ? item.threadId : ''); return { desc, threadId }; }).filter((item) => { if (!isValidThreadId(item.threadId) || seen.has(item.threadId)) return false; seen.add(item.threadId); return true; }); } function collectSubscriptionFeedsFromPanel() { const parsed = []; const seen = new Map(); let valid = true; $('#subscription-feed-inputs-container .subscription-feed-row').each((idx, el) => { const $row = $(el); const desc = ($row.find('.subscription-feed-desc-input').val() || '').trim(); const uuid = ($row.find('.subscription-feed-uuid-input').val() || '').trim(); if (!desc && !uuid) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}组备注过长`); valid = false; return false; } if (!uuid) { toast(`第${idx + 1}组未指定订阅号`); valid = false; return false; } if (seen.has(uuid)) { const first = seen.get(uuid); const suffix = first.desc ? `(${first.desc})` : ''; toast(`第${idx + 1}组与第${first.index}组${suffix}订阅号重复`); valid = false; return false; } seen.set(uuid, { index: idx + 1, desc }); parsed.push({ desc, uuid }); }); return valid ? parsed : null; } function collectFavoriteThreadsFromPanel() { const parsed = []; const seen = new Map(); let valid = true; $('#favorite-thread-inputs-container .favorite-thread-row').each((idx, el) => { const $row = $(el); const desc = ($row.find('.favorite-thread-desc-input').val() || '').trim(); const rawThread = ($row.find('.favorite-thread-id-input').val() || '').trim(); const threadId = normalizeFavoriteThreadInput(rawThread); if (!desc && !rawThread) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}组备注过长`); valid = false; return false; } if (!rawThread) { toast(`第${idx + 1}组未指定串号或链接`); valid = false; return false; } if (!threadId) { toast(`第${idx + 1}组存在不合法串号或链接`); valid = false; return false; } if (seen.has(threadId)) { const first = seen.get(threadId); const suffix = first.desc ? `(${first.desc})` : ''; toast(`第${idx + 1}组与第${first.index}组${suffix}重复`); valid = false; return false; } seen.set(threadId, { index: idx + 1, desc }); parsed.push({ desc, threadId }); }); return valid ? parsed : null; } function buildFavoriteThreadRowHtml(index, item = {}) { const desc = item.desc || ''; const threadId = item.threadId || ''; return ` <div class="favorite-thread-row" style="position:relative;margin:10px 0 8px;"> <span style="position:absolute;top:-9px;left:10px;display:inline-block;padding:0 6px;font-size:12px;line-height:18px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;z-index:1;">#${index}</span> ${buildRowDragHandleHtml()} <button type="button" class="favorite-thread-delete" style="position:absolute;top:-9px;right:10px;width:20px;height:20px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:1;">${XDEX_SVG_X}</button> <div style="display:grid;grid-template-columns:minmax(0,2fr) minmax(0,3fr);gap:8px;align-items:flex-start;border:1px solid #bfa58f;border-radius:6px;padding:12px 10px 10px 26px;background:rgba(255,255,255,0.18);box-sizing:border-box;width:100%;"> <input class="favorite-thread-desc-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="备注(可选)" value="${Utils.escapeHTML ? Utils.escapeHTML(desc) : desc}"> <input class="favorite-thread-id-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="串号或串链接" value="${Utils.escapeHTML ? Utils.escapeHTML(threadId) : threadId}"> </div> </div>`; } // 默认订阅号读写:'' = 未设置(点“订阅EX”时每次弹窗选择) // 读取时校验 uuid 仍存在于订阅号列表,避免删除订阅号后残留失效默认值 function getDefaultSubscriptionFeedUuid() { try { const state = (typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state) ? SettingPanel.state : (GM_getValue(SettingPanel.key, {}) || {}); const uuid = String(state.defaultSubscriptionFeedUuid || '').trim(); if (!uuid) return ''; const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; return feeds.some((f) => String((f && f.uuid) || '').trim() === uuid) ? uuid : ''; } catch (e) { return ''; } } // 写入默认订阅号并打时间戳;取消默认('')同样打时间戳,供跨端按“新者胜”判定 function setDefaultSubscriptionFeedUuid(uuid) { try { const next = String(uuid || '').trim(); const base = (typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state) ? SettingPanel.state : Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); base.defaultSubscriptionFeedUuid = next; base.defaultSubscriptionFeedUpdatedAt = Date.now(); if (typeof SettingPanel !== 'undefined' && SettingPanel) SettingPanel.state = base; GM_setValue(SettingPanel.key, base); try { if (SettingPanel && typeof SettingPanel.syncInputs === 'function') SettingPanel.syncInputs(); } catch (e) {} return true; } catch (e) { return false; } } function buildSubscriptionFeedRowHtml(index, item = {}) { const desc = item.desc || ''; const uuid = item.uuid || ''; const isDefault = !!uuid && uuid === getDefaultSubscriptionFeedUuid(); const checkedAttr = isDefault ? ' checked' : ''; return ` <div class="subscription-feed-row" style="position:relative;margin:10px 0 8px;"> <span style="position:absolute;top:-9px;left:10px;display:inline-block;padding:0 6px;font-size:12px;line-height:18px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;z-index:1;">#${index}</span> ${buildRowDragHandleHtml()} <button type="button" class="subscription-feed-delete" style="position:absolute;top:-9px;right:10px;width:20px;height:20px;border:1px solid #a98f7a;border-radius:999px;background:#F0E0D6;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:1;">${XDEX_SVG_X}</button> <div style="display:grid;grid-template-columns:minmax(0,2fr) minmax(0,3fr);gap:8px;align-items:flex-start;border:1px solid #bfa58f;border-radius:6px;padding:12px 10px 10px 26px;background:rgba(255,255,255,0.18);box-sizing:border-box;width:100%;"> <input class="subscription-feed-desc-input" style="width:100%;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="备注(可选)" value="${Utils.escapeHTML ? Utils.escapeHTML(desc) : desc}"> <div style="display:flex;align-items:center;gap:8px;min-width:0;"> <input class="subscription-feed-uuid-input" style="flex:1;min-width:0;padding:5px 8px;box-sizing:border-box;border-radius:8px;" placeholder="订阅号(任意字符串)" value="${Utils.escapeHTML ? Utils.escapeHTML(uuid) : uuid}"> <div style="display:flex;align-items:center;gap:6px;flex:0 0 auto;white-space:nowrap;"> <input type="checkbox" id="subscription-feed-default-${index}" class="xdex-switch subscription-feed-default" role="switch"${checkedAttr}> <label for="subscription-feed-default-${index}" style="font-size:12px;color:#6f5d50;user-select:none;">默认</label> </div> </div> </div> </div>`; } // ─── 浏览历史 / 发言历史 / 订阅 ─── const THREAD_HISTORY_STORAGE_KEY = 'xdex_thread_history'; const THREAD_HISTORY_STORE_VERSION = 1; // const THREAD_HISTORY_LIMIT = 500; const THREAD_HISTORY_LIMIT = Infinity; const THREAD_HISTORY_EXCERPT_LIMIT = 250; const THREAD_HISTORY_RECORD_RETRY_LIMIT = 10; const THREAD_HISTORY_RECORD_RETRY_DELAY = 500; const THREAD_HISTORY_SYNC_EVENT = 'xdex:thread-history-changed'; const THREAD_HISTORY_LIVE_RENDER_DEBOUNCE_DELAY = 300; const THREAD_HISTORY_LIVE_RENDER_MAX_WAIT = 1500; const THREAD_HISTORY_REVISIT_DWELL_MS = 5000; const HISTORY_SEARCH_INPUT_DEBOUNCE_MS = 300; const POST_HISTORY_STORAGE_KEY = 'xdex_post_history'; const POST_HISTORY_STORE_VERSION = 1; // const POST_HISTORY_LIMIT = 500; const POST_HISTORY_THREAD_LIMIT = Infinity; const POST_HISTORY_REPLY_LIMIT = Infinity; const POST_HISTORY_SYNC_EVENT = 'xdex:post-history-changed'; // 大键只承载数据;跨标签同步只订阅几十字节的版本键。 // 否则每次全量写都会把整份历史(MB 级)投递给每个注册了大键监听的标签页。 const THREAD_HISTORY_REV_KEY = 'xdex_thread_history_rev'; const POST_HISTORY_REV_KEY = 'xdex_post_history_rev'; let historyRevSeq = 0; function nextHistoryRev() { // 同一毫秒内连续写入也必须得到不同值,否则存储层可能判定“未变化”而不发变更事件 historyRevSeq = (historyRevSeq + 1) % 1000; return `${Date.now().toString(36)}-${historyRevSeq.toString(36)}`; } // 迁移桥:旧版本只写大键、不写版本键。仅当版本键尚不存在时临时监听大键, // 等新版本写入一次版本键后立即移除,避免长期承担整值投递。 function bindHistoryValueChangeSync(bigKey, revKey, onChange) { let legacyBridgeId = null; const removeLegacyBridge = () => { if (legacyBridgeId === null) return; const id = legacyBridgeId; legacyBridgeId = null; try { if (typeof GM_removeValueChangeListener === 'function') GM_removeValueChangeListener(id); } catch (e) {} }; if (typeof GM_addValueChangeListener !== 'function') return; GM_addValueChangeListener(revKey, (_key, _oldValue, _newValue, remote) => { removeLegacyBridge(); onChange(remote); }); // 版本键存在说明已迁移完成,不再注册大键监听——这是消除“标签页数 × MB”的关键。 // 大键监听只作为旧标签页写数据的兼容桥,不能因一次旧写入就移除(否则会丢后续旧写入的实时同步)。 if (GM_getValue(revKey, null) == null) { legacyBridgeId = GM_addValueChangeListener(bigKey, (_key, _oldValue, _newValue, remote) => { onChange(remote); }); } } const POST_HISTORY_API_BASE = `${location.origin}/Api`; const POST_HISTORY_REF_API_FALLBACK_BASE = 'https://api.nmb.best/api'; const POST_HISTORY_THREAD_API_BASE = 'https://api.nmb.best/api'; const POST_HISTORY_GET_LAST_POST_RETRY_DELAYS = [300, 800, 1500, 2500]; const POST_HISTORY_CONFIRM_TIMEOUT_MS = 10000; const POST_HISTORY_REPLIES_PER_PAGE = 19; const POST_HISTORY_MATCH_TIME_WINDOW_MS = 45000; const POST_HISTORY_FORUM_FID_MAP = Object.freeze({ '-1': '时间线', '4': '综合版1', '98': 'DANGER/U/', '20': '欢乐恶搞', '121': '速报2', // 测试版不在公开 getForumList,但 showf?id=122 可用;主串仅红名可见,发言历史需靠 showf 回查 '122': '测试', '17': '绘画(二创)', '110': '社畜(校园)', '19': '故事(小说)', '81': '都市怪谈(灵异)', '37': '军武', '30': '技术宅(代码)', '75': '数码(装机)', '118': '宠物', '97': '女装(时尚)', '106': '买买买(物品推荐)', '14': '动画综合', '12': '漫画', '53': '婆罗门一', '31': '电影/电视', '116': '主播管人(圈内)', '45': '卡牌桌游', '9': '特摄(布袋戏)', '102': '战锤', '39': '胶佬(手办)', '94': '铁道厨(车辆)', '6': 'VOCALOID', '90': '小马(美漫)', '5': '东方Project', '93': '舰娘', '111': '跑团', '57': '创作茶水间', '91': '规则怪谈', '11': '海龟汤(推理)', '15': '科学(干货)', '103': '文学(推书)', '35': '音乐(推歌)', '27': 'AI(Chatgpt)', '115': '摄影(cos)', '112': 'ROLL点', '2': '游戏综合', '3': '手游专楼', '25': '任天堂NS', '22': '腾讯游戏(LOL)', '23': '暴雪游戏', '124': 'SE(FF14)', '70': 'V社(DOTA)', '28': '怪物猎人', '68': '鹰角游戏', '47': '米哈游', '34': '音游打卡', '10': '联机(服务器发布)', '62': '露营', '113': '育儿', '120': '自救互助', '32': '料理(美食)', '33': '体育(健身)', '56': '学业打卡', '89': '日记(树洞)', '18': '值班室', '117': '技术支持', '96': '版务', '60': '三百人委员会' }); const POST_HISTORY_FORUM_GROUP_MAP = Object.freeze({ '-1': '综合', '4': '综合', '98': '综合', '20': '综合', '121': '综合', '122': '综合', '17': '综合', '110': '综合', '19': '综合', '81': '综合', '37': '综合', '30': '综合', '75': '综合', '118': '综合', '97': '综合', '106': '综合', '14': '亚文化', '12': '亚文化', '53': '亚文化', '31': '亚文化', '116': '亚文化', '45': '亚文化', '9': '亚文化', '102': '亚文化', '39': '亚文化', '94': '亚文化', '6': '亚文化', '90': '亚文化', '5': '亚文化', '93': '亚文化', '111': '创作', '57': '创作', '91': '创作', '11': '创作', '15': '创作', '103': '创作', '35': '创作', '27': '创作', '115': '创作', '112': '创作', '2': '游戏', '3': '游戏', '25': '游戏', '22': '游戏', '23': '游戏', '124': '游戏', '70': '游戏', '28': '游戏', '68': '游戏', '47': '游戏', '34': '游戏', '10': '游戏', '62': '生活', '113': '生活', '120': '生活', '32': '生活', '33': '生活', '56': '生活', '89': '生活', '18': '管理', '117': '管理', '96': '管理', '60': '管理' }); const POST_HISTORY_FORUM_SEARCH_META = Object.freeze({ '98': { rawName: 'DANGER_U', showName: 'DANGER/U/', groupName: '综合' }, '122': { rawName: '测试', showName: '测试', groupName: '综合' }, '17': { rawName: '绘画', showName: '绘画(二创)', groupName: '综合' }, '110': { rawName: '社畜', showName: '社畜(校园)', groupName: '综合' }, '19': { rawName: '故事', showName: '故事(小说)', groupName: '综合' }, '81': { rawName: '都市怪谈', showName: '都市怪谈(灵异)', groupName: '综合' }, '30': { rawName: '技术宅', showName: '技术宅(代码)', groupName: '综合' }, '75': { rawName: '数码', showName: '数码(装机)', groupName: '综合' }, '97': { rawName: '女装2', showName: '女装(时尚)', groupName: '综合' }, '106': { rawName: '买买买', showName: '买买买(物品推荐)', groupName: '综合' }, '31': { rawName: '影视', showName: '电影/电视', groupName: '亚文化' }, '116': { rawName: '主播管人', showName: '主播管人(圈内)', groupName: '亚文化' }, '9': { rawName: '特摄', showName: '特摄(布袋戏)', groupName: '亚文化' }, '39': { rawName: '胶佬', showName: '胶佬(手办)', groupName: '亚文化' }, '94': { rawName: '铁道厨', showName: '铁道厨(车辆)', groupName: '亚文化' }, '90': { rawName: '小马', showName: '小马(美漫)', groupName: '亚文化' }, '11': { rawName: '海龟汤', showName: '海龟汤(推理)', groupName: '创作' }, '15': { rawName: '科学', showName: '科学(干货)', groupName: '创作' }, '103': { rawName: '文学', showName: '文学(推书)', groupName: '创作' }, '35': { rawName: '音乐', showName: '音乐(推歌)', groupName: '创作' }, '27': { rawName: 'AI', showName: 'AI(Chatgpt)', groupName: '创作' }, '115': { rawName: '摄影', showName: '摄影(cos)', groupName: '创作' }, '25': { rawName: '任天堂', showName: '任天堂NS', groupName: '游戏' }, '22': { rawName: '腾讯游戏', showName: '腾讯游戏(LOL)', groupName: '游戏' }, '124': { rawName: 'SE', showName: 'SE(FF14)', groupName: '游戏' }, '70': { rawName: 'V社', showName: 'V社(DOTA)', groupName: '游戏' }, '10': { rawName: '联机', showName: '联机(服务器发布)', groupName: '游戏' }, '32': { rawName: '料理', showName: '料理(美食)', groupName: '生活' }, '33': { rawName: '体育', showName: '体育(健身)', groupName: '生活' }, '89': { rawName: '日记', showName: '日记(树洞)', groupName: '生活' }, '60': { rawName: '百脑汇', showName: '三百人委员会', groupName: '管理' } }); const POST_HISTORY_TIMELINE_ID_MAP = Object.freeze({ '1': '综合线', '2': '创作线', '3': '非创作线', '4': '亚文化线', '5': '综合2线', '6': '游戏线', '7': '生活线' }); const THREAD_HISTORY_SEARCH_HELP_TEXT = '普通关键词:串号、标题、名称、饼干、正文\n高级检索:\nmode:po 只看 Po 串\nmode:normal 普通串\nhas:image 带图\nhas:gif GIF\nhas:zwsp 或 has:zerowidth 含零宽字符\nhas:sage 被 SAGE 的串\n可组合:mode:po has:image has:sage 关键词'; const postHistoryConfirmationMap = new Map(); // 等待发串确认后跳转的 Promise 存储器 { localId -> resolver } const POST_HISTORY_SEARCH_HELP_TEXT = '普通关键词:发言 No、串号、板块、标题、名称、Email、正文、饼干、状态\n高级检索:\nstatus:confirmed 已确认\nstatus:pending 确认中\nstatus:failed 失败\nstatus:unconfirmed 未确认\nfid:98 指定板块 ID\nforum:综合 模糊匹配板块显示名/本名/分组名\nthread:64180270 指定串号\nid:68821620 指定发言 No\npage:203 指定页码\ncookie:abc123 指定饼干\nname:无名氏 指定名称\nemail:sage 指定 Email\nhas:image 带图\nhas:gif GIF\nhas:zwsp 或 has:zerowidth 含零宽字符\n可组合:forum:综合 has:image 关键词'; const ZERO_WIDTH_RE = /[\u200B\u200C\u200D\uFEFF]/; const threadHistoryDebugState = { loadedAt: new Date().toISOString(), href: location.href, runtime: XDEX_RUNTIME && XDEX_RUNTIME.kind, storageKey: THREAD_HISTORY_STORAGE_KEY, lastRecord: null, lastRender: null, lastPanelModule: '' }; window.__xdexThreadHistoryDebug = threadHistoryDebugState; let threadHistoryLiveSyncBound = false; let threadHistoryLiveRenderTimer = 0; let threadHistoryLiveRenderFirstAt = 0; let threadHistoryLiveRenderPendingCount = 0; let threadHistoryReactivationTrackingInstalled = false; let threadHistoryDwellTimer = 0; let threadHistoryVisibleSince = 0; let threadHistoryVisibleSessionCounted = false; let postHistoryLiveSyncBound = false; let postHistoryLiveRenderTimer = 0; let postHistoryLiveRenderFirstAt = 0; let postHistoryLiveRenderPendingCount = 0; let postHistoryLiveRenderDirty = false; let postHistoryActiveType = 'reply'; function updateThreadHistoryDebugState(patch) { Object.assign(threadHistoryDebugState, patch || {}); window.__xdexThreadHistoryDebug = threadHistoryDebugState; return threadHistoryDebugState; } function logThreadHistory(message, details, level = 'info') { const payload = Object.assign({ href: location.href }, details || {}); updateThreadHistoryDebugState({ lastLog: { message, details: payload, at: new Date().toISOString() } }); const logger = console[level] || console.info || console.log; logger.call(console, `[thread-history] ${message}`, payload); } function logThreadHistoryFlat(message, details, level = 'info') { const payload = Object.assign({ href: location.href }, details || {}); const text = Object.keys(payload) .map(key => `${key}=${JSON.stringify(payload[key])}`) .join(' '); updateThreadHistoryDebugState({ lastFlatLog: { message, details: payload, at: new Date().toISOString() } }); const logger = console[level] || console.info || console.log; logger.call(console, `[thread-history] ${message} ${text}`); } function createDefaultThreadHistoryStore() { return { version: 1, limit: THREAD_HISTORY_LIMIT, items: {}, index: {}, tombstones: {}, order: [] }; } function getThreadHistoryKey(mode, threadId) { return `${mode}:${String(threadId || '').slice(0, 8)}`; } function normalizeThreadHistoryStore(rawStore) { const store = Object.assign(createDefaultThreadHistoryStore(), rawStore || {}); store.version = THREAD_HISTORY_STORE_VERSION; // limit 一律以常量为准:旧版本持久化的 limit:500 不再沿用(否则 Infinity 设置永不生效);Infinity 经 JSON 序列化为 null,读回后由此行回填 store.limit = THREAD_HISTORY_LIMIT; store.items = store.items && typeof store.items === 'object' ? store.items : {}; store.index = store.index && typeof store.index === 'object' ? store.index : {}; // 墓碑: 软删除记录(deletedAt/origin/purged/revivedAt/record)。TTL 到期降级 purged(丢快照保留压制); // purged 超长安全期后物理清除; revivedAt 传播标记超期一并清除 store.tombstones = store.tombstones && typeof store.tombstones === 'object' ? store.tombstones : {}; { const nowTs = Date.now(); Object.keys(store.tombstones).forEach((tKey) => { const t = store.tombstones[tKey]; if (!t || typeof t !== 'object') { delete store.tombstones[tKey]; return; } const deletedAt = Number(t.deletedAt) || 0; if (t.purged) { if (nowTs - deletedAt > THREAD_HISTORY_TOMBSTONE_HARD_TTL_MS) delete store.tombstones[tKey]; return; } if (t.revivedAt) { if (nowTs - (Number(t.revivedAt) || 0) > THREAD_HISTORY_TOMBSTONE_TTL_MS) delete store.tombstones[tKey]; return; } if (deletedAt && nowTs - deletedAt > THREAD_HISTORY_TOMBSTONE_TTL_MS) { store.tombstones[tKey] = { deletedAt, origin: t.origin || 'local', purged: true }; } }); } const seen = new Set(); store.order = (Array.isArray(store.order) ? store.order : []) .filter(key => { if (!store.items[key] || seen.has(key)) return false; seen.add(key); return true; }); Object.keys(store.items).forEach(key => { const item = store.items[key]; const idx = store.index[key]; // 失步自愈:指纹覆盖 searchText 与谓词来源字段,任一变更即重建;存量无 fp 的索引首次读取补齐 if (!idx || idx.fp !== threadHistoryIndexFingerprint(item)) { store.index[key] = buildThreadHistoryIndexEntry(item); } // 页码一致性自愈: 修复 page=97/maxVisitedPage=97/url=?page=86/lastKnownPage=86 类自相矛盾的存量脏数据 // (WebDAV 裸展开或过时快照钳制的产物)。不变量: 串页数只增不减 → maxVisitedPage/lastKnownPage 只允许抬升 { const pageNum = Math.max(1, Number(item.page) || 1); const maxVp = Math.max(pageNum, Number(item.maxVisitedPage) || 0); const lastKp = Number(item.lastKnownPage) || 0; let urlPage = 0; if (item.url) { const pu = parseThreadHistoryUrl(item.url); urlPage = (pu && pu.page) || 0; } if (Number(item.maxVisitedPage) !== maxVp || (lastKp && lastKp < maxVp) || (urlPage && urlPage !== pageNum)) { item.maxVisitedPage = maxVp; if (!lastKp || lastKp < maxVp) item.lastKnownPage = maxVp; // url 永远跟随 page(最近查看页): 双向脱钩都按 page 重建 if (!item.url || (urlPage && urlPage !== pageNum)) item.url = buildThreadHistoryPageUrl(item.mode, item.threadId, pageNum); } } if (!seen.has(key)) { seen.add(key); store.order.push(key); } }); Object.keys(store.index).forEach(key => { if (!store.items[key]) delete store.index[key]; }); store.order.sort((a, b) => { const av = Number(store.items[a] && store.items[a].lastVisitedAt) || 0; const bv = Number(store.items[b] && store.items[b].lastVisitedAt) || 0; return bv - av; }); while (store.order.length > store.limit) { const key = store.order.pop(); delete store.items[key]; delete store.index[key]; } return store; } // 归一化结果记忆化:检索随输入触发,写入远少于读取;缓存由 setThreadHistoryStore 与跨标签变更监听失效 let threadHistoryStoreCache = null; function invalidateThreadHistoryStoreCache() { threadHistoryStoreCache = null; } function getThreadHistoryStore() { if (threadHistoryStoreCache) return threadHistoryStoreCache; let store; try { store = normalizeThreadHistoryStore(GM_getValue(THREAD_HISTORY_STORAGE_KEY, null)); } catch (e) { store = createDefaultThreadHistoryStore(); } threadHistoryStoreCache = store; return store; } function setThreadHistoryStore(store) { const normalized = normalizeThreadHistoryStore(store); GM_setValue(THREAD_HISTORY_STORAGE_KEY, normalized); GM_setValue(THREAD_HISTORY_REV_KEY, nextHistoryRev()); threadHistoryStoreCache = normalized; notifyThreadHistoryStoreChanged('local-write', false); return normalized; } function isThreadHistoryPanelOpen() { const cover = document.getElementById('sp_cover'); const module = document.getElementById('sp_module_history'); return !!module && module.classList.contains('active') && (!cover || getComputedStyle(cover).display !== 'none'); } function scheduleThreadHistoryLiveRender(source, remote) { const active = isThreadHistoryPanelOpen(); const now = Date.now(); if (!threadHistoryLiveRenderFirstAt) threadHistoryLiveRenderFirstAt = now; threadHistoryLiveRenderPendingCount += 1; updateThreadHistoryDebugState({ lastLiveSync: { source, remote: !!remote, active, pendingCount: threadHistoryLiveRenderPendingCount, firstAt: threadHistoryLiveRenderFirstAt, at: new Date().toISOString() } }); if (!active) { if (threadHistoryLiveRenderTimer) clearTimeout(threadHistoryLiveRenderTimer); threadHistoryLiveRenderTimer = 0; threadHistoryLiveRenderFirstAt = 0; threadHistoryLiveRenderPendingCount = 0; return; } const run = () => { threadHistoryLiveRenderTimer = 0; threadHistoryLiveRenderFirstAt = 0; threadHistoryLiveRenderPendingCount = 0; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => renderThreadHistoryModule()); else renderThreadHistoryModule(); }; if (threadHistoryLiveRenderTimer) clearTimeout(threadHistoryLiveRenderTimer); const elapsed = now - threadHistoryLiveRenderFirstAt; const delay = elapsed >= THREAD_HISTORY_LIVE_RENDER_MAX_WAIT ? 0 : Math.min(THREAD_HISTORY_LIVE_RENDER_DEBOUNCE_DELAY, THREAD_HISTORY_LIVE_RENDER_MAX_WAIT - elapsed); threadHistoryLiveRenderTimer = setTimeout(run, delay); } function notifyThreadHistoryStoreChanged(source, remote) { try { window.dispatchEvent(new CustomEvent(THREAD_HISTORY_SYNC_EVENT, { detail: { source, remote: !!remote, at: Date.now() } })); } catch (e) {} scheduleThreadHistoryLiveRender(source, remote); } function bindThreadHistoryLiveSync() { if (threadHistoryLiveSyncBound) return; threadHistoryLiveSyncBound = true; if (typeof GM_addValueChangeListener === 'function') { try { bindHistoryValueChangeSync(THREAD_HISTORY_STORAGE_KEY, THREAD_HISTORY_REV_KEY, (remote) => { invalidateThreadHistoryStoreCache(); scheduleThreadHistoryLiveRender('gm-value-change', remote); syncFavoriteThreadsLinks(); }); } catch (e) { logThreadHistory('live sync listener failed', { error: e && e.message ? e.message : String(e) }, 'warn'); } } window.addEventListener(THREAD_HISTORY_SYNC_EVENT, (event) => { const detail = event && event.detail || {}; scheduleThreadHistoryLiveRender(detail.source || 'window-event', !!detail.remote); }); } function createDefaultPostHistoryStore() { return { version: POST_HISTORY_STORE_VERSION, // limit: POST_HISTORY_LIMIT, items: {}, index: {}, tombstones: {}, order: [] }; } function normalizePostHistoryType(type) { return type === 'reply' ? 'reply' : 'thread'; } function normalizePostHistoryStatus(status) { return ['pending', 'confirmed', 'unconfirmed', 'failed'].includes(status) ? status : 'pending'; } function normalizePostHistoryFid(fid) { const value = String(fid == null ? '' : fid).trim(); return /^-?\d+$/.test(value) ? value : ''; } function getPostHistoryForumNameByFid(fid) { return POST_HISTORY_FORUM_FID_MAP[normalizePostHistoryFid(fid)] || ''; } function normalizeHistorySearchValue(value) { return String(value == null ? '' : value).trim().toLowerCase(); } function getPostHistoryForumSearchText(item) { const fid = normalizePostHistoryFid(item && item.fid); const meta = POST_HISTORY_FORUM_SEARCH_META[fid] || {}; return [ fid, item && item.forumName, getPostHistoryForumNameByFid(fid), meta.rawName, meta.showName, meta.groupName || POST_HISTORY_FORUM_GROUP_MAP[fid] ].join(' ').toLowerCase(); } function getPostHistoryPostFid(post) { if (!post || typeof post !== 'object') return ''; return normalizePostHistoryFid(post.fid || post.Fid || post.forum_id || post.forumId || post.forum); } function getCurrentPostHistoryFid() { const path = String(location && location.pathname || ''); const forumMatch = path.match(/^\/f\/([^/?#]+)/); if (!forumMatch) return ''; let forumName = ''; try { forumName = decodeURIComponent(forumMatch[1] || ''); } catch (e) { forumName = forumMatch[1] || ''; } const normalizedName = forumName.replace(/\s+/g, '').toLowerCase(); return Object.keys(POST_HISTORY_FORUM_FID_MAP).find(fid => { const name = String(POST_HISTORY_FORUM_FID_MAP[fid] || '').replace(/\s+/g, '').toLowerCase(); return name === normalizedName; }) || ''; } function normalizePostHistoryText(text) { // 旧版(破坏性):暴力替换空白、移除标签、trim — 已禁用 // return String(text || '') // .replace(/<br\s*\/?\s*>/gi, ' ') // .replace(/<[^>]+>/g, '') // .replace(/ /gi, ' ') // .replace(/>/gi, '>') // .replace(/</gi, '<') // .replace(/&/gi, '&') // .replace(/\r\n/g, '\n') // .replace(/\r/g, '\n') // .replace(/[\s\u00a0]+/g, ' ') // .trim(); // 新版:忠实保留原始内容 return String(text || '') .replace(/<br\s*\/?\s*>/gi, '\n') // <br> → 换行(不是空格) .replace(/>/gi, '>') // 符号转义 .replace(/</gi, '<') .replace(/&/gi, '&') .replace(/ /gi, '\u00a0') // → 不间断空格(保留语义) .replace(/\r\n/g, '\n') .replace(/\r/g, '\n'); // 不移除 HTML 标签,保留原始结构 // 不合并空白字符,保留原始空格/全角空格/零宽空格 // 不 trim,保留原始首尾空白 } function sanitizePostHistoryServerContentHtml(content) { const wrapper = document.createElement('div'); wrapper.innerHTML = String(content || ''); return sanitizeThreadHistoryContentHtml(wrapper); } function hashPostHistoryText(text) { // 旧版:直接调用 normalizePostHistoryText — 已禁用(normalizePostHistoryText 已改为忠实模式) // const normalized = normalizePostHistoryText(text); // 新版:内部独立标准化,保持 hash 计算一致性 const normalized = String(text || '') .replace(/<br\s*\/?\s*>/gi, ' ') .replace(/<[^>]+>/g, '') .replace(/ /gi, ' ') .replace(/>/gi, '>') .replace(/</gi, '<') .replace(/&/gi, '&') .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/[\s\u00a0]+/g, ' ') .trim(); let hash = 0; for (let i = 0; i < normalized.length; i++) { hash = ((hash << 5) - hash + normalized.charCodeAt(i)) | 0; } return String(hash >>> 0); } const postHistoryDebugState = window.__xdexPostHistoryDebug = window.__xdexPostHistoryDebug || { events: [] }; function summarizePostHistoryText(text) { const normalized = normalizePostHistoryText(text); return { length: normalized.length, hash: hashPostHistoryText(normalized), preview: normalized.slice(0, 40) }; } function summarizePostHistoryCandidate(post) { if (!post) return null; const resto = String(post.resto || '0').trim(); return { id: String(post.id || '').trim(), resto, type: Number(resto) === 0 ? 'thread' : 'reply', now: post.now || '', img: post.img || '', ext: post.ext || '', content: summarizePostHistoryText(post.content || '') }; } function summarizePostHistorySnapshot(snapshot) { if (!snapshot) return null; return { localId: snapshot.localId || '', type: snapshot.type || '', resto: snapshot.resto || '', contentHash: snapshot.contentHash || '', submittedAt: snapshot.submittedAt || 0, sourceUrl: snapshot.sourceUrl || '' }; } function logPostHistory(stage, data, level = 'log') { const detail = Object.assign({ stage, at: Date.now() }, data || {}); try { postHistoryDebugState.events.push(detail); if (postHistoryDebugState.events.length > 80) postHistoryDebugState.events.shift(); postHistoryDebugState.last = detail; } catch (e) {} const method = console[level] ? level : 'log'; console[method]('[post-history] ' + stage, detail); } window.__xdexGetPostHistoryDebug = function getPostHistoryDebug() { return postHistoryDebugState; }; window.__xdexClearPostHistoryDebug = function clearPostHistoryDebug() { postHistoryDebugState.events = []; postHistoryDebugState.last = null; return postHistoryDebugState; }; // 检索索引指纹:仅拼接短标量字段,成本远低于构建 searchText(含全文);指纹一致即复用旧索引 function postHistoryIndexFingerprint(item) { return [ item.id, item.postId, item.resto, item.threadId, item.fid, item.forumName, item.title, item.name, item.email, item.userHash, item.status, item.type, item.page, item.url, item.sourceUrl, item.imageFile, item.imageImg, item.imageExt, item.contentHash, (item.contentText || '').length, (item.contentRaw || '').length ].join('\u0001'); } // 发言历史检索索引:与浏览历史同构,把检索期字符串拼接与正则判定前移到写入期 function buildPostHistoryIndexEntry(item) { return { fp: postHistoryIndexFingerprint(item), searchText: buildPostHistorySearchText(item), forumText: getPostHistoryForumSearchText(item), type: item.type, status: item.status, fid: item.fid, hasImage: !!item.imageFile, isGif: /\.gif(?:$|[?#])/i.test(String(item.imageFile || item.imageExt || '')), hasZeroWidth: ZERO_WIDTH_RE.test(String(item.contentRaw || item.contentText || '')) }; } function normalizePostHistoryStore(rawStore) { const store = Object.assign(createDefaultPostHistoryStore(), rawStore || {}); store.version = POST_HISTORY_STORE_VERSION; // store.limit = Number(store.limit) > 0 ? Number(store.limit) : POST_HISTORY_LIMIT; store.items = store.items && typeof store.items === 'object' ? store.items : {}; // 检索索引:存量数据无此字段,首次读取即补齐(派生数据,旧版本代码忽略不影响) store.index = store.index && typeof store.index === 'object' ? store.index : {}; // 墓碑: 与浏览历史同构。TTL 到期降级 purged; purged 超安全期清除; revivedAt 标记超期清除 store.tombstones = store.tombstones && typeof store.tombstones === 'object' ? store.tombstones : {}; { const nowTs = Date.now(); Object.keys(store.tombstones).forEach((tKey) => { const t = store.tombstones[tKey]; if (!t || typeof t !== 'object') { delete store.tombstones[tKey]; return; } const deletedAt = Number(t.deletedAt) || 0; if (t.purged) { if (nowTs - deletedAt > POST_HISTORY_TOMBSTONE_HARD_TTL_MS) delete store.tombstones[tKey]; return; } if (t.revivedAt) { if (nowTs - (Number(t.revivedAt) || 0) > POST_HISTORY_TOMBSTONE_TTL_MS) delete store.tombstones[tKey]; return; } if (deletedAt && nowTs - deletedAt > POST_HISTORY_TOMBSTONE_TTL_MS) { store.tombstones[tKey] = { deletedAt, origin: t.origin || 'local', purged: true }; } }); } const seen = new Set(); store.order = (Array.isArray(store.order) ? store.order : []) .filter(key => { if (!store.items[key] || seen.has(key)) return false; seen.add(key); return true; }); Object.keys(store.items).forEach(key => { const item = store.items[key] || {}; item.localId = item.localId || key; item.status = normalizePostHistoryStatus(item.status); item.type = normalizePostHistoryType(item.type); item.fid = normalizePostHistoryFid(item.fid); item.forumName = item.forumName || getPostHistoryForumNameByFid(item.fid); item.contentText = normalizePostHistoryText(item.contentText || item.contentRaw || ''); item.contentHash = item.contentHash || hashPostHistoryText(item.contentText); item.page = Math.max(0, Number(item.page) || 0); store.items[key] = item; const idx = store.index[key]; if (!idx || idx.fp !== postHistoryIndexFingerprint(item)) store.index[key] = buildPostHistoryIndexEntry(item); if (!seen.has(key)) { seen.add(key); store.order.push(key); } }); Object.keys(store.index).forEach(key => { if (!store.items[key]) delete store.index[key]; }); store.order.sort((a, b) => { const av = Number(store.items[a] && store.items[a].submittedAt) || 0; const bv = Number(store.items[b] && store.items[b].submittedAt) || 0; return bv - av; }); // 旧:全局共享 limit 清理 // while (store.order.length > store.limit) { // const key = store.order.pop(); // delete store.items[key]; // } // 新增:按类型分别清理(仅在 limit !== Infinity 时生效) const shouldCleanThread = POST_HISTORY_THREAD_LIMIT !== Infinity; const shouldCleanReply = POST_HISTORY_REPLY_LIMIT !== Infinity; if (shouldCleanThread || shouldCleanReply) { const typeOrders = { thread: [], reply: [] }; store.order.forEach(key => { const type = normalizePostHistoryType(store.items[key]?.type); typeOrders[type].push(key); }); while (shouldCleanThread && typeOrders.thread.length > POST_HISTORY_THREAD_LIMIT) { const key = typeOrders.thread.pop(); delete store.items[key]; } while (shouldCleanReply && typeOrders.reply.length > POST_HISTORY_REPLY_LIMIT) { const key = typeOrders.reply.pop(); delete store.items[key]; } store.order = store.order.filter(key => store.items[key]); } return store; } // 同浏览历史:归一化结果记忆化,失效点在 setPostHistoryStore 与跨标签变更监听 let postHistoryStoreCache = null; function invalidatePostHistoryStoreCache() { postHistoryStoreCache = null; } function getPostHistoryStore() { if (postHistoryStoreCache) return postHistoryStoreCache; let store; try { store = normalizePostHistoryStore(GM_getValue(POST_HISTORY_STORAGE_KEY, null)); } catch (e) { store = createDefaultPostHistoryStore(); } postHistoryStoreCache = store; return store; } function isPostHistoryPanelOpen() { const cover = document.getElementById('sp_cover'); const module = document.getElementById('sp_module_posts'); return !!module && module.classList.contains('active') && (!cover || getComputedStyle(cover).display !== 'none'); } function schedulePostHistoryLiveRender(source, remote) { const active = isPostHistoryPanelOpen(); const renderable = !!document.getElementById('sp_posts_results'); const now = Date.now(); if (!postHistoryLiveRenderFirstAt) postHistoryLiveRenderFirstAt = now; postHistoryLiveRenderPendingCount += 1; postHistoryLiveRenderDirty = true; logPostHistory('live sync', { source, remote: !!remote, active, renderable, pendingCount: postHistoryLiveRenderPendingCount, firstAt: postHistoryLiveRenderFirstAt }); // 与浏览历史一致:模块未打开时不渲染,避免 WebDAV 合并触发全量历史渲染造成可用性检测刷屏 if (!active) { if (postHistoryLiveRenderTimer) clearTimeout(postHistoryLiveRenderTimer); postHistoryLiveRenderTimer = 0; postHistoryLiveRenderFirstAt = 0; postHistoryLiveRenderPendingCount = 0; return; } const run = () => { postHistoryLiveRenderTimer = 0; postHistoryLiveRenderFirstAt = 0; postHistoryLiveRenderPendingCount = 0; postHistoryLiveRenderDirty = false; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => renderPostHistoryModule()); else renderPostHistoryModule(); }; if (postHistoryLiveRenderTimer) clearTimeout(postHistoryLiveRenderTimer); const elapsed = now - postHistoryLiveRenderFirstAt; const delay = elapsed >= THREAD_HISTORY_LIVE_RENDER_MAX_WAIT ? 0 : Math.min(THREAD_HISTORY_LIVE_RENDER_DEBOUNCE_DELAY, THREAD_HISTORY_LIVE_RENDER_MAX_WAIT - elapsed); postHistoryLiveRenderTimer = setTimeout(run, delay); } function notifyPostHistoryStoreChanged(source, remote) { try { window.dispatchEvent(new CustomEvent(POST_HISTORY_SYNC_EVENT, { detail: { source, remote: !!remote, at: Date.now() } })); } catch (e) {} logPostHistory('store notify', { source, remote: !!remote }); schedulePostHistoryLiveRender(source, remote); } // ★ 发言历史持久化写合并:150ms 窗口内的多次全量写只落盘最后一次。 // 背景:每次 GM_setValue 都触发整键序列化 //(QwQNT 下还有 localStorage 镜像 stringify + IPC 结构化克隆 + 主进程处理), // 导入/WebDAV 合并路径会在同一时刻连续写 threadHistory + postHistory 等多个大键, // 确认链(snapshot→confirm)也会产生多次全量写。合并窗口显著削减同 tick 连环写的总量。 // 一致性:postHistoryStoreCache 在调用方视角始终立即更新(读不受窗口影响); // 窗口到期才真正 GM_setValue,跨标签广播与 GM_addValueChangeListener 随真实写触发。 const POST_HISTORY_PERSIST_DEBOUNCE_MS = 150; let postHistoryPersistTimer = 0; let postHistoryPersistPending = null; function flushPostHistoryStorePersist() { if (postHistoryPersistTimer) { clearTimeout(postHistoryPersistTimer); postHistoryPersistTimer = 0; } if (postHistoryPersistPending === null) return; const pending = postHistoryPersistPending; postHistoryPersistPending = null; try { GM_setValue(POST_HISTORY_STORAGE_KEY, pending); GM_setValue(POST_HISTORY_REV_KEY, nextHistoryRev()); } catch (e) { console.warn('[postHistory] persist flush failed', e); } } function schedulePostHistoryStorePersist(normalized) { postHistoryPersistPending = normalized; if (postHistoryPersistTimer) return; postHistoryPersistTimer = setTimeout(() => { postHistoryPersistTimer = 0; flushPostHistoryStorePersist(); }, POST_HISTORY_PERSIST_DEBOUNCE_MS); } // 页面关闭/隐藏前兜底落盘,避免窗口期内最后一次写丢失 if (typeof document !== 'undefined' && !window.__xdexPostHistoryPersistFlushBound) { window.__xdexPostHistoryPersistFlushBound = true; const flushOnLeave = () => { // 页面隐藏/关闭:先把字段回填队列落盘(借道回填可能刚入队),再冲 persist 防抖窗口 try { flushPostHistoryFieldPatches(); } catch (e) {} flushPostHistoryStorePersist(); }; document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushOnLeave(); }); // pagehide 已覆盖导航/关闭时机;不挂 beforeunload——它在导航关键路径上, // 同步大对象 stringify+localStorage 写会拖慢「打开新网页」的旧页面卸载 window.addEventListener('pagehide', flushOnLeave); } // ★ 发言历史字段回填队列:thread fallback 拿到缺失字段(fid/forumName/page)后不立即 // 全量写 store,而是入队;下一次任意 setPostHistoryStore 时免费合并,或 30s 空闲兜底 // 一次性落盘。消除「回填 = 一次 2.9MB 全量写 + 2 轮广播」在发送后窗口内的额外卡顿。 const postHistoryFieldPatchQueue = new Map(); let postHistoryFieldPatchTimer = 0; const POST_HISTORY_FIELD_PATCH_FLUSH_MS = 1500; function applyPostHistoryFieldPatches(store) { if (!postHistoryFieldPatchQueue.size) return store; postHistoryFieldPatchQueue.forEach((patch, localId) => { const item = store.items && store.items[localId]; if (!item) { postHistoryFieldPatchQueue.delete(localId); return; } Object.keys(patch).forEach(key => { const cur = item[key]; const val = patch[key]; // 只补缺失/空值,不覆盖已有数据 if (cur === undefined || cur === null || cur === '' || cur === 0) item[key] = val; }); postHistoryFieldPatchQueue.delete(localId); }); return store; } function flushPostHistoryFieldPatches() { if (!postHistoryFieldPatchQueue.size) return; if (postHistoryFieldPatchTimer) { clearTimeout(postHistoryFieldPatchTimer); postHistoryFieldPatchTimer = 0; } const store = getPostHistoryStore(); if (!postHistoryFieldPatchQueue.size) return; applyPostHistoryFieldPatches(store); setPostHistoryStore(store); } function schedulePostHistoryFieldPatchFlush() { if (postHistoryFieldPatchTimer) return; postHistoryFieldPatchTimer = setTimeout(() => { postHistoryFieldPatchTimer = 0; try { if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => { try { flushPostHistoryFieldPatches(); } catch (e) {} }, { timeout: POST_HISTORY_FIELD_PATCH_FLUSH_MS + 3500 }); return; } } catch (e) { /* 回退直接执行 */ } try { flushPostHistoryFieldPatches(); } catch (e) {} }, POST_HISTORY_FIELD_PATCH_FLUSH_MS); } // ★ 借道确认:发送链已拉取的 thread 数据直接回填 pending 记录的缺失字段。 // 零新增请求、字段立即落盘(替代原 fallback 的独立 API 链与延迟写)。 // threadObj: /api/thread 响应对象(顶层含 fid);replies: 含刚发回复的 Replies 数组; // page: 该回复所在页码;snapshotConstraint: 可选 { threadId } 限定串。 function enrichPendingPostHistoryFromThreadData(threadObj, replies, page) { try { const store = getPostHistoryStore(); const now = Date.now(); const PENDING_TTL_MS = 180000; const fid = getPostHistoryPostFid(threadObj) || ''; let enriched = 0; (replies || []).forEach((reply) => { const rid = String(reply && reply.id || '').trim(); if (!rid) return; Object.keys(store.items).forEach((localId) => { const item = store.items[localId]; if (!item || item.status !== 'pending' || item.type !== 'reply') return; if (String(item.threadId || '') !== String(threadObj && threadObj.id || '')) return; if (now - (Number(item.submittedAt) || 0) > PENDING_TTL_MS) return; if (String(item.id || '') === rid || (!item.id && postHistoryMatchesSnapshot(reply, item, new Set()))) { const cand = buildPostHistoryThreadCandidate(reply, threadObj, page); postHistoryFieldPatchQueue.set(localId, { fid: fid || getPostHistoryPostFid(cand) || '', forumName: getPostHistoryForumNameByFid(fid) || '', page: Math.max(1, Number(cand.page) || page || 0), title: String(cand.title || ''), name: String(cand.name || ''), email: String(cand.email || '') }); enriched += 1; } }); }); if (enriched > 0) { logPostHistory('inline enrich', { count: enriched, page }); schedulePostHistoryFieldPatchFlush(); } return enriched; } catch (e) { logPostHistory('inline enrich error', { error: e && e.message ? e.message : String(e) }, 'warn'); return 0; } } function setPostHistoryStore(store) { // 先把排队的字段回填合并进本次写(0 额外写) if (postHistoryFieldPatchQueue.size) { try { applyPostHistoryFieldPatches(store); } catch (e) { /* 合并失败不阻塞主写 */ } if (postHistoryFieldPatchTimer) { clearTimeout(postHistoryFieldPatchTimer); postHistoryFieldPatchTimer = 0; } } const normalized = normalizePostHistoryStore(store); postHistoryStoreCache = normalized; schedulePostHistoryStorePersist(normalized); notifyPostHistoryStoreChanged('local-write', false); return normalized; } function bindPostHistoryLiveSync() { if (postHistoryLiveSyncBound) return; postHistoryLiveSyncBound = true; if (typeof GM_addValueChangeListener === 'function') { try { bindHistoryValueChangeSync(POST_HISTORY_STORAGE_KEY, POST_HISTORY_REV_KEY, (remote) => { invalidatePostHistoryStoreCache(); schedulePostHistoryLiveRender('gm-value-change', remote); }); } catch (e) { logPostHistory('live sync listener failed', { error: e && e.message ? e.message : String(e) }, 'warn'); } } window.addEventListener(POST_HISTORY_SYNC_EVENT, (event) => { const detail = event && event.detail || {}; schedulePostHistoryLiveRender(detail.source || 'window-event', !!detail.remote); }); } function buildCanonicalReplyUrl(threadId, replyId) { const tid = String(threadId || '').trim(); const rid = String(replyId || '').trim(); if (!tid || !rid) return ''; return `${location.origin}/t/${tid}?r=${rid}`; } function buildPostHistoryUrl(type, id, resto) { const postId = String(id || '').trim(); const threadId = String(type === 'reply' ? resto : id || '').trim(); if (!postId && !threadId) return ''; if (type === 'reply') return buildCanonicalReplyUrl(threadId, postId); // 主题也使用 ?r=threadId 格式 // return `${location.origin}/t/${threadId}?r=${threadId}`; // 主题:精简版 URL,不带 ?r=threadId return `${location.origin}/t/${threadId}`; } function buildPostHistoryReplyActionUrl(type, id, resto, page) { const postId = String(id || '').trim(); const threadId = String(type === 'reply' ? resto : id || '').trim(); const pageNum = Math.max(0, Number(page) || 0); // reply 类型保持原来的行为:有 page 就用 ?page=N&r=${postId}`;,否则回退到 ?r=replyId if (type === 'reply') { if (threadId && pageNum > 0) return `${location.origin}/t/${threadId}?page=${pageNum}&r=${postId}`; // if (threadId && pageNum > 0) return `${location.origin}/t/${threadId}?page=${pageNum}`; return buildPostHistoryUrl(type, postId, threadId); } // thread 类型优先从浏览历史获取最新页面(动态更新) const historyUrl = getLatestThreadHistoryUrl(threadId); if (historyUrl) return historyUrl; // 没有浏览历史,回退到发言历史记录的页面 const fallbackPage = Math.max(1, Number(page) || 1); if (threadId) return `${location.origin}/t/${threadId}?page=${fallbackPage}`; return buildPostHistoryUrl(type, postId, threadId); } function getConfirmedPostHistoryIds(store) { const ids = new Set(); Object.keys(store.items || {}).forEach(key => { const item = store.items[key]; if (item && item.status === 'confirmed' && item.id) ids.add(String(item.id)); }); return ids; } function upsertPostHistoryRecord(record) { if (!record || !record.localId) return getPostHistoryStore(); const store = getPostHistoryStore(); const old = store.items[record.localId] || {}; const merged = Object.assign({}, old, record, { localId: record.localId, status: normalizePostHistoryStatus(record.status), type: normalizePostHistoryType(record.type), contentText: normalizePostHistoryText(record.contentText || record.contentRaw || old.contentText || ''), contentHash: record.contentHash || old.contentHash || hashPostHistoryText(record.contentText || record.contentRaw || old.contentText || ''), submittedAt: Number(record.submittedAt || old.submittedAt) || Date.now() }); store.items[merged.localId] = merged; store.order = [merged.localId].concat((store.order || []).filter(key => key !== merged.localId)); const typeCounts = { thread: 0, reply: 0 }; store.order.forEach(key => { const type = normalizePostHistoryType(store.items[key]?.type); typeCounts[type]++; }); logPostHistory('store upsert', { localId: merged.localId, status: merged.status, type: merged.type, contentHash: merged.contentHash, submittedAt: merged.submittedAt, total: store.order.length, threadCount: typeCounts.thread, replyCount: typeCounts.reply }); return setPostHistoryStore(store); } function updatePostHistoryRecord(localId, patch) { const store = getPostHistoryStore(); if (!store.items[localId]) { logPostHistory('store update skipped', { localId, patch: patch || {} }, 'warn'); return store; } store.items[localId] = Object.assign({}, store.items[localId], patch || {}); logPostHistory('store update', { localId, patch: patch || {}, status: store.items[localId].status, type: store.items[localId].type, id: store.items[localId].id || '', resto: store.items[localId].resto || '' }); return setPostHistoryStore(store); } function deletePostHistoryItem(localId) { const store = getPostHistoryStore(); // 软删除(分家): 原记录搬入墓碑快照, 主列表移除; 30 天内可恢复 const item = store.items[localId]; if (item) { store.tombstones = store.tombstones || {}; store.tombstones[localId] = { deletedAt: Date.now(), origin: 'local', purged: false, revivedAt: null, record: Object.assign({}, item) }; } delete store.items[localId]; store.order = (store.order || []).filter(key => key !== localId); return setPostHistoryStore(store); } function restorePostHistoryFromTombstone(localId) { const store = getPostHistoryStore(); const tomb = (store.tombstones || {})[localId]; if (!tomb || tomb.purged || tomb.revivedAt) return false; const record = tomb.record || null; const shadow = store.items[localId] || null; let merged = null; if (record && shadow) { const recNewer = (Number(record.submittedAt) || 0) >= (Number(shadow.submittedAt) || 0); merged = Object.assign({}, shadow, record, { page: Math.max(Number(record.page) || 0, Number(shadow.page) || 0), submittedAt: Math.min(Number(record.submittedAt) || Infinity, Number(shadow.submittedAt) || Infinity) }); void recNewer; } else { merged = record ? Object.assign({}, record) : (shadow ? Object.assign({}, shadow) : null); } if (merged) { merged.localId = merged.localId || localId; store.items[localId] = merged; if (!(store.order || []).includes(localId)) store.order.push(localId); } store.tombstones = store.tombstones || {}; store.tombstones[localId] = { deletedAt: Number(tomb.deletedAt) || Date.now(), origin: tomb.origin || 'local', purged: false, revivedAt: Date.now() }; return setPostHistoryStore(store); } function purgePostHistoryTombstone(localId) { const store = getPostHistoryStore(); const tomb = (store.tombstones || {})[localId]; if (!tomb) return false; delete store.items[localId]; store.order = (store.order || []).filter(key => key !== localId); store.tombstones[localId] = { deletedAt: Number(tomb.deletedAt) || Date.now(), origin: tomb.origin || 'local', purged: true }; return setPostHistoryStore(store); } function purgeAllPostHistoryTombstones() { const store = getPostHistoryStore(); const tombs = store.tombstones || {}; const now = Date.now(); Object.keys(tombs).forEach((key) => { if (tombs[key].purged) return; delete store.items[key]; tombs[key] = { deletedAt: Number(tombs[key].deletedAt) || now, origin: tombs[key].origin || 'local', purged: true }; }); store.order = (store.order || []).filter(k => !tombs[k] || tombs[k].purged); return setPostHistoryStore(store); } function clearPostHistory() { // 批量软删除: 全部条目移入回收站; 30 天后自动彻底清除, 期间可恢复 const store = getPostHistoryStore(); const now = Date.now(); store.tombstones = store.tombstones || {}; Object.keys(store.items).forEach((key) => { if (store.tombstones[key] && !store.tombstones[key].purged && !store.tombstones[key].revivedAt) return; store.tombstones[key] = { deletedAt: now, origin: 'local', purged: false, revivedAt: null, record: Object.assign({}, store.items[key]) }; }); store.items = {}; store.order = []; return setPostHistoryStore(store); } function searchPostHistory(query, type) { const store = getPostHistoryStore(); const tombs = store.tombstones || {}; const index = store.index || {}; const selectedType = normalizePostHistoryType(type || postHistoryActiveType); const { filters, tokens } = parsePostHistorySearchQuery(query); return (store.order || []) .filter(key => { const entry = index[key]; const item = store.items[key]; if (!entry || !item) return false; // 回收站压制: 仅"无删除后新记录"的纯墓碑才隐藏; 重新添加/更新会重建 items 影子条目并带标识显示 const tomb = tombs[key]; if (tomb && !tomb.revivedAt && !item) return false; if (entry.type !== selectedType) return false; if (filters.statusFilters.length && !filters.statusFilters.includes(entry.status)) return false; if (filters.fidFilters.length && !filters.fidFilters.includes(entry.fid)) return false; if (filters.forumFilters.length && !filters.forumFilters.every(value => entry.forumText.includes(value))) return false; if (filters.hasImage && !entry.hasImage) return false; if (filters.isGif && !entry.isGif) return false; if (filters.hasZeroWidth && !entry.hasZeroWidth) return false; if (filters.fieldFilters.length && !filters.fieldFilters.every(filter => getPostHistorySearchFieldText(item, filter.field).includes(filter.value))) return false; return tokens.every(token => entry.searchText.includes(token)); }) .map(key => ({ key, item: store.items[key] })); } function parsePostHistorySearchQuery(query) { const filters = { statusFilters: [], fidFilters: [], forumFilters: [], fieldFilters: [], hasImage: false, isGif: false, hasZeroWidth: false }; const tokens = []; String(query || '').split(/\s+/).filter(Boolean).forEach(rawToken => { const token = normalizeHistorySearchValue(rawToken); const pair = token.match(/^([a-z]+):(.+)$/); if (!pair) { tokens.push(token); return; } const key = pair[1]; const value = normalizeHistorySearchValue(pair[2]); if (!value) return; if (key === 'status' && ['pending', 'confirmed', 'unconfirmed', 'failed'].includes(value)) filters.statusFilters.push(value); else if (key === 'fid') { const fid = normalizePostHistoryFid(value); if (fid) filters.fidFilters.push(fid); } else if (key === 'forum') filters.forumFilters.push(value); else if (key === 'has' && value === 'image') filters.hasImage = true; else if (key === 'has' && value === 'gif') filters.isGif = true; else if (key === 'has' && (value === 'zwsp' || value === 'zerowidth')) filters.hasZeroWidth = true; else if (['id', 'thread', 'page', 'cookie', 'name', 'email'].includes(key)) filters.fieldFilters.push({ field: key, value }); else tokens.push(token); }); return { filters, tokens }; } function getPostHistorySearchFieldText(item, field) { if (field === 'id') return normalizeHistorySearchValue([item.id, item.postId].join(' ')); if (field === 'thread') return normalizeHistorySearchValue([item.threadId, item.resto].join(' ')); if (field === 'page') return normalizeHistorySearchValue(item.page); if (field === 'cookie') return normalizeHistorySearchValue(item.userHash); if (field === 'name') return normalizeHistorySearchValue(item.name); if (field === 'email') return normalizeHistorySearchValue(item.email); return ''; } function buildPostHistorySearchText(item) { return [ item.id, item.threadId, item.postId, item.resto, item.fid, item.forumName, getPostHistoryForumSearchText(item), item.title, item.name, item.email, item.contentText, item.contentRaw, item.userHash, item.status, item.type, item.page, item.url, item.sourceUrl, item.imageFile, item.imageImg, item.imageExt ].join(' ').toLowerCase(); } function parseLastPostResponse(resp, context) { const text = resp && (resp.responseText || resp.response) || ''; try { const json = typeof text === 'string' ? JSON.parse(text) : text; const data = json && (json.data || json.post || json); const post = Array.isArray(data) ? (data[0] || null) : data; if (!post) { logPostHistory('getLastPost empty', Object.assign({}, context || {}, { responseLength: String(text || '').length })); return null; } logPostHistory('getLastPost parse', Object.assign({}, context || {}, { candidate: summarizePostHistoryCandidate(post) })); return post; } catch (e) { logPostHistory('getLastPost parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(text || '').length, preview: String(text || '').slice(0, 80) }), 'warn'); return null; } } function fetchPostHistorySameOriginText(url, context, stage) { const label = stage || 'post history api'; logPostHistory(label + ' request', Object.assign({}, context || {}, { url })); return fetch(url, { credentials: 'include', cache: 'no-store' }).then(resp => { return resp.text().then(text => { logPostHistory(label + ' response', Object.assign({}, context || {}, { status: resp.status, responseLength: String(text || '').length })); if (!resp.ok) throw new Error(`HTTP ${resp.status} ${url}`); return { status: resp.status, statusText: resp.statusText, responseText: text, response: text, finalUrl: resp.url || url }; }); }).catch(e => { logPostHistory(label + ' error', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e) }), 'warn'); throw e; }); } function fetchLastPostHistoryPost(context) { const url = `${POST_HISTORY_API_BASE}/getLastPost`; return fetchPostHistorySameOriginText(url, context, 'getLastPost').then(resp => parseLastPostResponse(resp, context)); } function getPostHistoryApiCookieHeaders() { const userhash = getCurrentBrowserUserhash(); return userhash ? { Cookie: `userhash=${userhash}` } : null; } function buildPostHistoryImageFile(img, ext) { const base = String(img || '').trim(); const extValue = String(ext || '').trim(); if (!base) return ''; const suffix = extValue ? (extValue[0] === '.' ? extValue : `.${extValue}`) : ''; if (!suffix) return base; return base.toLowerCase().endsWith(suffix.toLowerCase()) ? base : base + suffix; } function parsePostHistoryRefResponse(resp, context) { const text = resp && (resp.responseText || resp.response) || ''; try { const json = typeof text === 'string' ? JSON.parse(text) : text; const data = json && (json.data || json.post || json); const refPost = data && !Array.isArray(data) ? data : null; if (refPost && refPost.success === false) throw new Error(refPost.error || 'ref api error'); if (!refPost) { logPostHistory('ref empty', Object.assign({}, context || {}, { responseLength: String(text || '').length })); return null; } logPostHistory('ref parse', Object.assign({}, context || {}, { id: refPost.id || '', imageFile: buildPostHistoryImageFile(refPost.img, refPost.ext) })); return refPost; } catch (e) { logPostHistory('ref parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(text || '').length, preview: String(text || '').slice(0, 80) }), 'warn'); return null; } } function postHistoryRefPostHasImage(refPost) { return !!buildPostHistoryImageFile(refPost && refPost.img, refPost && refPost.ext); } function parsePostHistoryRefHtmlResponse(resp, context) { const html = resp && (resp.responseText || resp.response) || ''; try { const doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); const root = doc.querySelector('.h-threads-img-box') || doc.querySelector('.h-threads-item-main') || doc.body; let imageFile = extractThreadHistoryImageFile(root); if (!imageFile) { const imageNode = doc.querySelector('.h-threads-img-a[href], img.h-threads-image, img.h-threads-img, a[href*="/image/"], a[href*="/thumb/"]'); const imageAnchor = imageNode && imageNode.closest ? imageNode.closest('a[href]') : imageNode; imageFile = normalizeThreadHistoryImageFile( (imageAnchor && imageAnchor.getAttribute && imageAnchor.getAttribute('href')) || (imageNode && imageNode.getAttribute && (imageNode.getAttribute('data-src') || imageNode.getAttribute('src'))) ); } if (!imageFile) { logPostHistory('ref html empty', Object.assign({}, context || {}, { responseLength: String(html || '').length })); return null; } logPostHistory('ref html parse', Object.assign({}, context || {}, { imageFile })); return { img: imageFile, ext: '', imageFile }; } catch (e) { logPostHistory('ref html parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(html || '').length, preview: String(html || '').slice(0, 80) }), 'warn'); return null; } } function fetchPostHistoryRefPost(id, context) { const postId = String(id || '').trim(); if (!postId) return Promise.resolve(null); const detail = Object.assign({}, context || {}, { id: postId }); // [停用-注释保留] 同源降级链(/Api/ref → /Home/Forum/ref)经实证(2026-08-18)对浏览器 UA 稳定返回 503/空,仅走主 API api.nmb.best/api/ref return fetchPostHistoryRefApiPost(postId, detail); // return fetchPostHistoryRefApiPost(postId, detail) // .then(refPost => { // if (postHistoryRefPostHasImage(refPost)) return refPost; // return fetchPostHistorySameOriginRefPost(postId, detail); // }) // .catch(() => fetchPostHistorySameOriginRefPost(postId, detail)); } function fetchPostHistoryRefApiPost(id, context) { const postId = String(id || '').trim(); if (!postId) return Promise.resolve(null); const url = `${POST_HISTORY_REF_API_FALLBACK_BASE}/ref?id=${encodeURIComponent(postId)}`; const headers = getPostHistoryApiCookieHeaders(); const detail = Object.assign({}, context || {}, { id: postId, api: true, authenticated: !!headers }); logPostHistory('ref api request', Object.assign({}, detail, { url })); return gmRequest(url, 'text', headers).then(resp => { logPostHistory('ref api response', Object.assign({}, detail, { status: resp.status, responseLength: String(resp.responseText || resp.response || '').length })); const refPost = parsePostHistoryRefResponse(resp, detail); return postHistoryRefPostHasImage(refPost) ? refPost : null; }).catch(e => { logPostHistory('ref api error', Object.assign({}, detail, { error: e && e.message ? e.message : String(e) }), 'warn'); throw e; }); } // [停用-注释保留] 同源降级(/Api/ref):经实证(2026-08-18)该端点对浏览器 UA 稳定返回 503,恢复时取消注释并恢复 fetchPostHistoryRefPost 内调用即可 // function fetchPostHistorySameOriginRefPost(id, context) { // const postId = String(id || '').trim(); // if (!postId) return Promise.resolve(null); // const url = `${POST_HISTORY_API_BASE}/ref?id=${encodeURIComponent(postId)}`; // const detail = Object.assign({}, context || {}, { id: postId, sameOriginFallback: true }); // return fetchPostHistorySameOriginText(url, detail, 'ref same-origin fallback') // .then(resp => { // const refPost = parsePostHistoryRefResponse(resp, detail); // if (postHistoryRefPostHasImage(refPost)) return refPost; // return fetchPostHistoryRefHtmlFallbackPost(postId, detail); // }) // .catch(() => fetchPostHistoryRefHtmlFallbackPost(postId, detail)); // } // [停用-注释保留] HTML 降级(/Home/Forum/ref):实证返回空页,随 /Api/ref 降级一并停用 // function fetchPostHistoryRefHtmlFallbackPost(id, context) { // const postId = String(id || '').trim(); // if (!postId) return Promise.resolve(null); // const url = `/Home/Forum/ref?id=${encodeURIComponent(postId)}`; // const detail = Object.assign({}, context || {}, { id: postId, htmlFallback: true }); // return fetchPostHistorySameOriginText(url, detail, 'ref html fallback') // .then(resp => parsePostHistoryRefHtmlResponse(resp, detail)); // } function enrichPostHistoryRefImage(localId, postId) { return fetchPostHistoryRefPost(postId, { localId }).then(refPost => { const imageFile = refPost ? buildPostHistoryImageFile(refPost.img, refPost.ext) : ''; if (!imageFile) return false; updatePostHistoryRecord(localId, { imageFile, imageImg: refPost.img || '', imageExt: refPost.ext || '' }); return true; }).catch(e => { logPostHistory('ref image error', { localId, id: postId, error: e && e.message ? e.message : String(e) }, 'warn'); return false; }); } function parsePostHistoryThreadResponse(resp, context) { const text = resp && (resp.responseText || resp.response) || ''; try { const thread = typeof text === 'string' ? JSON.parse(text) : text; if (thread && thread.success === false) throw new Error(thread.error || 'thread api error'); const replies = Array.isArray(thread && thread.Replies) ? thread.Replies : []; const replyCount = Number(thread && (thread.ReplyCount || thread.replyCount || thread.reply_count)) || replies.length; logPostHistory('thread fallback parse', Object.assign({}, context || {}, { replyCount, replies: replies.length })); return { thread, replies, replyCount, page: Math.max(1, Number(context && context.page) || 1) }; } catch (e) { logPostHistory('thread fallback parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(text || '').length, preview: String(text || '').slice(0, 80) }), 'warn'); throw e; } } function fetchPostHistoryThreadPage(threadId, page, context) { const detail = Object.assign({}, context || {}, { threadId, page }); // [停用-注释保留] 同源降级(/Api/thread)经实证(2026-08-18)对浏览器 UA 稳定返回 503,仅走主 API api.nmb.best/api/thread return fetchPostHistoryThreadApiPage(threadId, page, detail); // return fetchPostHistoryThreadApiPage(threadId, page, detail) // .catch(() => fetchPostHistorySameOriginThreadPage(threadId, page, detail)); } function fetchPostHistoryThreadApiPage(threadId, page, context) { const url = `${POST_HISTORY_THREAD_API_BASE}/thread?id=${encodeURIComponent(threadId)}&page=${encodeURIComponent(page)}`; const headers = getPostHistoryApiCookieHeaders(); const detail = Object.assign({}, context || {}, { threadId, page, api: true, authenticated: !!headers }); logPostHistory('thread api request', Object.assign({}, detail, { url })); return gmRequest(url, 'text', headers).then(resp => { logPostHistory('thread api response', Object.assign({}, detail, { status: resp.status, responseLength: String(resp.responseText || resp.response || '').length })); return parsePostHistoryThreadResponse(resp, detail); }).catch(e => { logPostHistory('thread api error', Object.assign({}, detail, { error: e && e.message ? e.message : String(e) }), 'warn'); throw e; }); } // [停用-注释保留] 同源降级(/Api/thread):经实证(2026-08-18)该端点对浏览器 UA 稳定返回 503,恢复时取消注释并恢复 fetchPostHistoryThreadPage 内的 catch 即可 // function fetchPostHistorySameOriginThreadPage(threadId, page, context) { // const url = `${POST_HISTORY_API_BASE}/thread?id=${encodeURIComponent(threadId)}&page=${encodeURIComponent(page)}`; // const detail = Object.assign({}, context || {}, { threadId, page }); // return fetchPostHistorySameOriginText(url, detail, 'thread same-origin fallback').then(resp => parsePostHistoryThreadResponse(resp, detail)); // } function getPostHistoryThreadFallbackPages(replyCount) { const total = Number(replyCount) || 0; const tailPage = Math.max(1, Math.ceil(total / POST_HISTORY_REPLIES_PER_PAGE)); const pages = [tailPage, tailPage - 1] .filter(page => page >= 1) .map(page => Math.max(1, Number(page) || 1)); return Array.from(new Set(pages)).sort((a, b) => b - a); } function buildPostHistoryThreadCandidate(reply, thread, page) { const threadId = String(thread && thread.id || '').trim(); const fid = getPostHistoryPostFid(reply) || getPostHistoryPostFid(thread); return Object.assign({}, reply || {}, { fid, resto: threadId, page: Math.max(1, Number(page) || 1) }); } function findPostHistoryThreadFallbackMatch(pageData, snapshot, usedIds) { const replies = Array.isArray(pageData && pageData.replies) ? pageData.replies : []; for (let i = replies.length - 1; i >= 0; i--) { const candidate = buildPostHistoryThreadCandidate(replies[i], pageData && pageData.thread || { id: snapshot && snapshot.resto, fid: snapshot && snapshot.fid }, pageData && pageData.page); if (postHistoryMatchesSnapshot(candidate, snapshot, usedIds)) return candidate; } return null; } const POST_HISTORY_THREAD_PAGE_RETRY_DELAYS = [5000, 15000, 30000, 60000, 120000]; // 主路径确认后的 thread 尾页回退调度:错峰到空闲时段,避免发送后的存储写敏感窗口。 // 只调度一次(pending 标记防重复);页面在此期间关闭则放弃(字段缺口无碍主数据)。 function schedulePostHistoryThreadFallback(localId, snapshot) { const run = () => { try { completePostHistoryFromThreadFallback(localId, snapshot, 0); } catch (e) { logPostHistory('thread fallback schedule error', { localId, error: e && e.message ? e.message : String(e) }, 'warn'); } }; const IDLE_DELAY_MS = 5000; try { if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => setTimeout(run, 0), { timeout: IDLE_DELAY_MS + 3000 }); return; } } catch (e) { /* 回退 setTimeout */ } setTimeout(run, IDLE_DELAY_MS); } async function completePostHistoryFromThreadFallback(localId, snapshot, retryAttempt) { if (!snapshot || snapshot.type !== 'reply' || !String(snapshot.resto || '').trim()) { logPostHistory('thread fallback exhausted', { localId, reason: 'unsupported-snapshot', snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); return false; } const threadId = String(snapshot.resto || '').trim(); try { const firstPage = await fetchPostHistoryThreadPage(threadId, 1, { localId, phase: 'count', retryAttempt: retryAttempt || 0 }); const pages = getPostHistoryThreadFallbackPages(firstPage.replyCount); const usedIds = getConfirmedPostHistoryIds(getPostHistoryStore()); for (const page of pages) { const pageData = page === 1 ? firstPage : await fetchPostHistoryThreadPage(threadId, page, { localId, phase: 'scan' }); const post = findPostHistoryThreadFallbackMatch(pageData, snapshot, usedIds); if (post) { logPostHistory('thread fallback confirmed', { localId, page, retryAttempt: retryAttempt || 0, candidate: summarizePostHistoryCandidate(post) }); // ★ 只回填主路径拿不到的字段(fid/forumName/page/title 等),入队等待合并写: // 不再走 confirmPostHistorySnapshot(那会整包 update + 再次状态翻转 = 又一次 // 2.9MB 全量存储写 + 2 轮广播,实测是发送后卡顿的残留源)。条目状态仍为 // 主路径已写的 confirmed,无字段翻转风险。 // 借道回填通常已补齐字段(fid 非空)→ 无缺口则跳过,避免一次空的全量写 const existingItem = getPostHistoryStore().items[localId]; const needsBackfill = !existingItem || !existingItem.fid || !existingItem.page; if (needsBackfill) { postHistoryFieldPatchQueue.set(localId, { fid: getPostHistoryPostFid(post) || '', forumName: getPostHistoryForumNameByFid(getPostHistoryPostFid(post)) || '', page: Math.max(1, Number(post.page) || 0), title: String(post.title || ''), name: String(post.name || ''), email: String(post.email || '') }); schedulePostHistoryFieldPatchFlush(); } const resolver = postHistoryConfirmationMap.get(localId); if (resolver) { try { resolver({ localId, fromFallback: true }); postHistoryConfirmationMap.delete(localId); } catch (e) {} } return true; } } logPostHistory('thread fallback exhausted', { localId, pages, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); return false; } catch (e) { const attempt = retryAttempt || 0; if (attempt < POST_HISTORY_THREAD_PAGE_RETRY_DELAYS.length) { const delay = POST_HISTORY_THREAD_PAGE_RETRY_DELAYS[attempt]; logPostHistory('thread page verify retry scheduled', { localId, threadId, attempt, nextAttempt: attempt + 1, delay, error: e && e.message ? e.message : String(e) }); setTimeout(() => { completePostHistoryFromThreadFallback(localId, snapshot, attempt + 1); }, delay); return false; } logPostHistory('thread page verify error', { localId, threadId, attempts: attempt + 1, error: e && e.message ? e.message : String(e) }, 'warn'); return false; } } // showf 版面主串回退:新开串在 getLastPost 失败、且 thread fallback 不适用时,按 fid 扫 /showf // 典型场景:/f/测试(fid=122)主串不进时间线、对普通浏览半隐身,但 showf?id=122 可回查刚发主串 const POST_HISTORY_SHOWF_PAGE_RETRY_DELAYS = [5000, 15000, 30000, 60000, 120000]; function parsePostHistoryShowfResponse(resp, context) { const text = resp && (resp.responseText || resp.response) || ''; try { const json = typeof text === 'string' ? JSON.parse(text) : text; if (json && json.success === false) throw new Error(json.error || 'showf api error'); const posts = Array.isArray(json) ? json : (Array.isArray(json && json.data) ? json.data : []); logPostHistory('showf fallback parse', Object.assign({}, context || {}, { posts: posts.length })); return { posts, page: Math.max(1, Number(context && context.page) || 1) }; } catch (e) { logPostHistory('showf fallback parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(text || '').length, preview: String(text || '').slice(0, 80) }), 'warn'); throw e; } } function fetchPostHistoryShowfPage(fid, page, context) { const detail = Object.assign({}, context || {}, { fid, page }); return fetchPostHistoryShowfApiPage(fid, page, detail) .catch(() => fetchPostHistorySameOriginShowfPage(fid, page, detail)); } function fetchPostHistoryShowfApiPage(fid, page, context) { const url = `${POST_HISTORY_SHOWF_API_BASE}/showf?id=${encodeURIComponent(fid)}&page=${encodeURIComponent(page)}`; const headers = getPostHistoryApiCookieHeaders(); const detail = Object.assign({}, context || {}, { fid, page, api: true, authenticated: !!headers }); logPostHistory('showf api request', Object.assign({}, detail, { url })); return gmRequest(url, 'text', headers).then(resp => { logPostHistory('showf api response', Object.assign({}, detail, { status: resp.status, responseLength: String(resp.responseText || resp.response || '').length })); return parsePostHistoryShowfResponse(resp, detail); }).catch(e => { logPostHistory('showf api error', Object.assign({}, detail, { error: e && e.message ? e.message : String(e) }), 'warn'); throw e; }); } function fetchPostHistorySameOriginShowfPage(fid, page, context) { const url = `${POST_HISTORY_API_BASE}/showf?id=${encodeURIComponent(fid)}&page=${encodeURIComponent(page)}`; const detail = Object.assign({}, context || {}, { fid, page }); return fetchPostHistorySameOriginText(url, detail, 'showf same-origin fallback').then(resp => parsePostHistoryShowfResponse(resp, detail)); } function buildPostHistoryShowfCandidate(post, page, snapshotFid) { const fid = getPostHistoryPostFid(post) || normalizePostHistoryFid(snapshotFid); // showf 列表项是主串;resto 缺省时按 0 处理,便于 postHistoryMatchesSnapshot 归类为 thread return Object.assign({}, post || {}, { fid, resto: String(post && post.resto != null ? post.resto : '0'), page: Math.max(1, Number(page) || 1) }); } function findPostHistoryShowfFallbackMatch(pageData, snapshot, usedIds) { const posts = Array.isArray(pageData && pageData.posts) ? pageData.posts : []; // 版面列表通常新串在前;从前往后扫即可,匹配逻辑仍靠内容/时间窗 for (let i = 0; i < posts.length; i++) { const candidate = buildPostHistoryShowfCandidate(posts[i], pageData && pageData.page, snapshot && snapshot.fid); if (postHistoryMatchesSnapshot(candidate, snapshot, usedIds)) return candidate; } return null; } async function completePostHistoryFromShowfFallback(localId, snapshot, retryAttempt) { if (!snapshot || snapshot.type !== 'thread') { logPostHistory('showf fallback exhausted', { localId, reason: 'unsupported-snapshot', snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); return false; } const fid = normalizePostHistoryFid(snapshot.fid) || getCurrentPostHistoryFid(); if (!fid) { logPostHistory('showf fallback exhausted', { localId, reason: 'missing-fid', snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); return false; } try { // 只扫前两页:新开串通常在 page=1;page=2 覆盖并发/排序抖动 const pages = [1, 2]; const usedIds = getConfirmedPostHistoryIds(getPostHistoryStore()); for (const page of pages) { const pageData = await fetchPostHistoryShowfPage(fid, page, { localId, phase: 'scan', retryAttempt: retryAttempt || 0 }); const post = findPostHistoryShowfFallbackMatch(pageData, Object.assign({}, snapshot, { fid }), usedIds); if (post) { logPostHistory('showf fallback confirmed', { localId, fid, page, retryAttempt: retryAttempt || 0, candidate: summarizePostHistoryCandidate(post) }); confirmPostHistorySnapshot(localId, post); return true; } } logPostHistory('showf fallback exhausted', { localId, fid, pages, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); return false; } catch (e) { const attempt = retryAttempt || 0; if (attempt < POST_HISTORY_SHOWF_PAGE_RETRY_DELAYS.length) { const delay = POST_HISTORY_SHOWF_PAGE_RETRY_DELAYS[attempt]; logPostHistory('showf page verify retry scheduled', { localId, fid, attempt, nextAttempt: attempt + 1, delay, error: e && e.message ? e.message : String(e) }); setTimeout(() => { completePostHistoryFromShowfFallback(localId, snapshot, attempt + 1); }, delay); return false; } logPostHistory('showf page verify error', { localId, fid, attempts: attempt + 1, error: e && e.message ? e.message : String(e) }, 'warn'); return false; } } function postHistoryMatchesSnapshot(post, snapshot, usedIds) { const reject = (reason, extra) => { logPostHistory('match rejected', Object.assign({ reason, snapshot: summarizePostHistorySnapshot(snapshot), candidate: summarizePostHistoryCandidate(post) }, extra || {})); return false; }; if (!post || !snapshot) return reject('missing-post-or-snapshot'); const id = String(post.id || '').trim(); if (!id) return reject('missing-id'); const expectedId = String(snapshot.id || snapshot.postId || '').trim(); if (expectedId && id !== expectedId) return reject('id-mismatch', { expectedId, actualId: id }); if (usedIds.has(id) && id !== expectedId) return reject('duplicate-confirmed-id', { id }); const resto = String(post.resto || '0').trim(); const type = Number(resto) === 0 ? 'thread' : 'reply'; if (type !== snapshot.type) return reject('type-mismatch', { expectedType: snapshot.type, actualType: type }); if (type === 'reply' && String(snapshot.resto || '').trim() && String(snapshot.resto || '').trim() !== resto) return reject('reply-resto-mismatch', { expectedResto: String(snapshot.resto || '').trim(), actualResto: resto }); const postText = normalizePostHistoryText(post.content || ''); if (postText && snapshot.contentHash && hashPostHistoryText(postText) !== snapshot.contentHash && postText !== snapshot.contentText) { logPostHistory('server content differs', { snapshot: summarizePostHistorySnapshot(snapshot), candidate: summarizePostHistoryCandidate(post), expectedHash: snapshot.contentHash, actualHash: hashPostHistoryText(postText) }); } const postTs = Date.parse(post.now || ''); if (!postTs) return reject('missing-time'); const timeDiff = Math.abs(postTs - Number(snapshot.submittedAt || Date.now())); if (timeDiff > POST_HISTORY_MATCH_TIME_WINDOW_MS) return reject('time-window-mismatch', { postTs, submittedAt: Number(snapshot.submittedAt || Date.now()), timeDiff }); logPostHistory('match accepted', { snapshot: summarizePostHistorySnapshot(snapshot), candidate: summarizePostHistoryCandidate(post), timeDiff }); return true; } function confirmPostHistorySnapshot(localId, post) { const resto = String(post.resto || '0').trim(); const type = Number(resto) === 0 ? 'thread' : 'reply'; const id = String(post.id || '').trim(); const url = buildPostHistoryUrl(type, id, resto); const existing = getPostHistoryStore().items[localId] || {}; const existingPage = type === 'thread' ? (Number(existing.page) || 0) : 0; const imageFile = buildPostHistoryImageFile(post.img, post.ext); const serverContentRaw = post.content || ''; const serverContentText = normalizePostHistoryText(serverContentRaw); const fid = getPostHistoryPostFid(post) || normalizePostHistoryFid(existing.fid); const update = { status: 'confirmed', type, id, resto, threadId: type === 'reply' ? resto : id, postId: id, page: Math.max(0, Number(post.page) || existingPage || (type === 'thread' ? 1 : 0)), fid, forumName: getPostHistoryForumNameByFid(fid), title: post.title || '', email: post.email || '', contentRaw: serverContentRaw, contentText: serverContentText, contentHash: hashPostHistoryText(serverContentText), contentHtml: sanitizePostHistoryServerContentHtml(serverContentRaw), userHash: post.user_hash || post.userHash || '', confirmedAt: Date.now(), url }; if (imageFile) Object.assign(update, { imageFile, imageImg: post.img || '', imageExt: post.ext || '' }); logPostHistory('confirmed', { localId, type, id, resto, url }); updatePostHistoryRecord(localId, update); // 新串发布成功 → 自动写入串内饼干偏好 if (type === 'thread' && update.userHash) { try { setThreadCookiePref(id, update.userHash); if (typeof refreshCookiePrefSwitchState === 'function') refreshCookiePrefSwitchState(); } catch (e) {} } const resolver = postHistoryConfirmationMap.get(localId); if (!imageFile) { // 没有图片,异步获取后再通知等待者 enrichPostHistoryRefImage(localId, id).then(() => { if (resolver) { resolver(Object.assign({ localId }, update)); postHistoryConfirmationMap.delete(localId); } }).catch(() => { if (resolver) { resolver(Object.assign({ localId }, update)); postHistoryConfirmationMap.delete(localId); } }); } else { // 已有图片,直接通知等待者 if (resolver) { resolver(Object.assign({ localId }, update)); postHistoryConfirmationMap.delete(localId); } } } function completePostHistorySnapshot(localId, snapshot, attempt = 0) { const delay = POST_HISTORY_GET_LAST_POST_RETRY_DELAYS[attempt]; if (delay == null) { logPostHistory('completion exhausted', { localId, attempt, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); // 清理等待者(确认失败或超时) const resolver = postHistoryConfirmationMap.get(localId); if (resolver) { resolver(null); postHistoryConfirmationMap.delete(localId); } // reply → thread 尾页回退;thread(含测试版主串)→ showf 版面回退 completePostHistoryFromThreadFallback(localId, snapshot).then(confirmed => { if (confirmed) return true; return completePostHistoryFromShowfFallback(localId, snapshot); }).then(confirmed => { if (confirmed) return; logPostHistory('unconfirmed', { localId, attempt, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); updatePostHistoryRecord(localId, { status: 'unconfirmed' }); }).catch(e => { logPostHistory('thread fallback error', { localId, error: e && e.message ? e.message : String(e) }, 'warn'); // thread 路径异常时仍尝试 showf(新开串场景) completePostHistoryFromShowfFallback(localId, snapshot).then(confirmed => { if (confirmed) return; logPostHistory('unconfirmed', { localId, attempt, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); updatePostHistoryRecord(localId, { status: 'unconfirmed' }); }).catch(showfErr => { logPostHistory('showf fallback error', { localId, error: showfErr && showfErr.message ? showfErr.message : String(showfErr) }, 'warn'); logPostHistory('unconfirmed', { localId, attempt, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); updatePostHistoryRecord(localId, { status: 'unconfirmed' }); }); }); return; } logPostHistory('completion scheduled', { localId, attempt, delay, snapshot: summarizePostHistorySnapshot(snapshot) }); setTimeout(() => { fetchLastPostHistoryPost({ localId, attempt }).then(post => { const store = getPostHistoryStore(); if (!postHistoryMatchesSnapshot(post, snapshot, getConfirmedPostHistoryIds(store))) { logPostHistory('completion retry', { localId, attempt, nextAttempt: attempt + 1 }); completePostHistorySnapshot(localId, snapshot, attempt + 1); return; } const id = String(post.id || '').trim(); const confirmedResto = String(post.resto || snapshot.resto || '').trim(); const confirmedSnapshot = Object.assign({}, snapshot, { id, postId: id, resto: confirmedResto, threadId: confirmedResto }); confirmPostHistorySnapshot(localId, post); // ★ 主路径确认后仍需 thread 尾页回退:getLastPost 不返回 fid/forumName/page/title // 等字段(实测历史条目缺「技术支持/所在页」等),fallback 的 thread 页 candidate // 负责补齐。但它会再次 confirm → 一次全量存储写 + 2 轮跨标签广播(每次广播 // 渲染端 valuesEqual 双 stringify 大对象)——错峰到空闲时段执行,避免叠加在 // 发送后 0-3s 的滚动敏感窗口(实测该窗口内 3 次全量写 = 持续卡顿)。 if (confirmedSnapshot.type === 'reply') { schedulePostHistoryThreadFallback(localId, confirmedSnapshot); } }).catch(e => { logPostHistory('completion retry', { localId, attempt, nextAttempt: attempt + 1, error: e && e.message ? e.message : String(e) }, 'warn'); completePostHistorySnapshot(localId, snapshot, attempt + 1); }); }, delay); } // ★ 轻量确认(发言历史关闭时专用):只通过 getLastPost 重试链取回新串地址用于跳转, // 完全不写发言历史 store(无 upsert/update/广播)。 function confirmPostHistoryUrlOnly(fd, options) { const type = options && options.isReply ? 'reply' : 'thread'; const contentRaw = fd && fd.get ? String(fd.get('content') || '') : ''; const contentText = normalizePostHistoryText(contentRaw); const contentHash = hashPostHistoryText(contentText); const resto = fd && fd.get ? String(fd.get('resto') || '').trim() : ''; const title = fd && fd.get ? String(fd.get('title') || '') : ''; const probes = { status: 'pending', type, localId: '', id: '', postId: '', resto, threadId: type === 'reply' ? resto : '', title, contentText, contentHash, userHash: '', submittedAt: Date.now() }; const delays = POST_HISTORY_GET_LAST_POST_RETRY_DELAYS; return new Promise((resolve) => { const attemptFetch = (attempt) => { const delay = delays[attempt]; if (delay == null) { resolve(null); return; } setTimeout(() => { fetchLastPostHistoryPost({ localId: 'url-only', attempt }).then(post => { // 复用主链同源匹配判据(null 安全;usedIds 用空集——只确认不落库) if (!postHistoryMatchesSnapshot(post, probes, new Set())) { attemptFetch(attempt + 1); return; } const id = String(post.id || '').trim(); resolve(id ? { url: buildPostHistoryUrl(type, id, String(post.resto || resto || '').trim()), id } : null); }).catch(() => { attemptFetch(attempt + 1); }); }, delay); }; attemptFetch(0); }); } function snapshotSubmittedPostHistory(fd, options) { // 发言历史已关闭:不在发送消息后自动记录。但发串跳转(postAfterAction=jump/refresh) // 依赖确认后的地址——走"只确认不落库"的轻量路径(getLastPost 重试取 id,不写 store)。 if (!isPostHistoryEnabledNow()) { const isReplyDisabled = !!(options && options.isReply); return { snapshot: null, localId: null, confirmPromise: isReplyDisabled ? Promise.resolve(null) : confirmPostHistoryUrlOnly(fd, options) }; } const type = options && options.isReply ? 'reply' : 'thread'; const submittedAt = Date.now(); const contentRaw = fd && fd.get ? String(fd.get('content') || '') : ''; const contentText = normalizePostHistoryText(contentRaw); const resto = fd && fd.get ? String(fd.get('resto') || '').trim() : ''; const fallbackFid = getCurrentPostHistoryFid(); const localId = `local-${submittedAt}-${Math.random().toString(36).slice(2, 8)}`; const parsedSource = parseThreadHistoryUrl(location.href); const snapshot = { status: 'pending', type, localId, id: '', resto, threadId: type === 'reply' ? resto : '', postId: '', page: type === 'thread' ? (parsedSource ? parsedSource.page : 1) : 0, fid: fallbackFid, forumName: getPostHistoryForumNameByFid(fallbackFid), title: fd && fd.get ? String(fd.get('title') || '') : '', name: fd && fd.get ? String(fd.get('name') || '') : '', email: fd && fd.get ? String(fd.get('email') || '') : '', contentRaw, contentText, contentHash: hashPostHistoryText(contentText), userHash: '', submittedAt, confirmedAt: 0, sourceUrl: location.href, url: '' }; // 为发串创建一个可等待的 Promise,用于确认后跳转 let confirmResolver; const confirmPromise = new Promise(res => { confirmResolver = res; }); postHistoryConfirmationMap.set(localId, confirmResolver); logPostHistory('snapshot', { snapshot: summarizePostHistorySnapshot(snapshot), content: summarizePostHistoryText(contentText) }); upsertPostHistoryRecord(snapshot); completePostHistorySnapshot(localId, snapshot, 0); return { snapshot, localId, confirmPromise }; } function parseThreadHistoryUrl(inputUrl) { let url; try { url = new URL(inputUrl || location.href, location.origin); } catch (e) { return null; } const path = url.pathname || ''; // 普通串支持 /t/12345678 与 /t/12345678/page/323(不再识别笔误的 /t/12345678/323) const normalMatch = path.match(/\/t\/(\d{6,8})(?:\/page\/(\d+))?/); const poMatch = path.match(/\/Forum\/po\/id\/(\d{6,8})(?:\/page\/(\d+)\.html)?/); const match = normalMatch || poMatch; if (!match) return null; const pathPage = parseInt(match[2] || '', 10); const queryPage = parseInt(url.searchParams.get('page') || '', 10); return { mode: poMatch ? 'po' : 'normal', threadId: String(match[1]).slice(0, 8), page: pathPage > 0 ? pathPage : (queryPage > 0 ? queryPage : 1), url: url.toString() }; } function buildThreadHistoryPageUrl(mode, threadId, page) { const tid = String(threadId || '').trim(); const pageNum = Math.max(1, Number(page) || 1); if (!tid) return location.href; if (mode === 'po') return `${location.origin}/Forum/po/id/${tid}/page/${pageNum}.html`; // 普通串页码路径:/t/{id}/page/{n}(第 1 页保留 /t/{id}) if (pageNum <= 1) return `${location.origin}/t/${tid}`; return `${location.origin}/t/${tid}/page/${pageNum}`; } function parseThreadHistoryPageNumberFromElement(el) { if (!el) return 0; const text = String(el.textContent || '').trim(); const lastTextMatch = text.match(/末页\s*\((\d+)\)/); if (lastTextMatch) return Number(lastTextMatch[1]) || 0; const href = el.getAttribute && el.getAttribute('href'); const parsed = href ? parseThreadHistoryUrl(href) : null; if (parsed && parsed.page) return Number(parsed.page) || 0; const numericText = text.match(/^\d+$/); return numericText ? Number(numericText[0]) || 0 : 0; } function getThreadHistoryPaginationBounds(root = document) { const paginations = Array.from((root || document).querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination')); if (!paginations.length) return null; // 无缝翻页会给每页追加一个克隆分页栏;HTTP 缓存可能返回旧页面 → 某些栏的"末页"过时偏小。 // 真实总页数 >= 任何栏显示的末页 → 跨所有栏取最大值,免疫缓存旧栏把 page/lastKnownPage 钢制回退 let best = null; for (const pagination of paginations) { const items = Array.from(pagination.querySelectorAll('li')); const elements = Array.from(pagination.querySelectorAll('a, span')); const parsedLinks = elements .map(el => parseThreadHistoryUrl(el.getAttribute && el.getAttribute('href'))) .filter(Boolean); const parsedIdentity = parsedLinks.find(parsed => parsed.threadId); const lastPageLink = elements.find(el => /^末页/.test(String(el.textContent || '').trim())); const activeEl = pagination.querySelector('li.uk-active a, li.uk-active span'); const activePage = parseThreadHistoryPageNumberFromElement(activeEl); const nextItem = items.find(li => /下一页|下页|Next|›|»|→/i.test(String(li.textContent || '').trim())); const nextHasLink = !!(nextItem && nextItem.querySelector('a[href]')); const numericPages = elements .map(parseThreadHistoryPageNumberFromElement) .filter(num => num > 0); let lastPage = parseThreadHistoryPageNumberFromElement(lastPageLink); if (!lastPage && nextItem && !nextHasLink) { lastPage = activePage || Math.max(0, ...numericPages); } if (!lastPage) continue; const candidate = { lastPage, activePage, threadId: parsedIdentity && parsedIdentity.threadId || '', mode: parsedIdentity && parsedIdentity.mode || '', source: lastPageLink ? 'last-link' : 'disabled-next' }; if (!best || lastPage > best.lastPage) best = candidate; } return best; } function applyThreadHistoryPageBounds(record, root = document, knownMaxPage = 0) { if (!record || !record.threadId) return record; const bounds = getThreadHistoryPaginationBounds(root); if (!bounds || !bounds.lastPage) return record; if (bounds.threadId && bounds.threadId !== String(record.threadId)) return record; if (bounds.mode && record.mode && bounds.mode !== record.mode) return record; // ── 过时快照写入闸门 ── // 串页数只增不减。分页栏快照末页 < 已确认进度(真实访问过的更后页) → 分页栏来自旧缓存 // (整页 HTTP 缓存/无缝翻页克隆的旧栏), 据此钳制会把 page/maxVisitedPage/url 打回旧页码。 // 拒绝本次快照的一切降级写入; 快照末页 ≥ 已知进度时才有资格参与钳制。 const knownMax = Math.max(Number(knownMaxPage) || 0, 0); if (knownMax > 1 && bounds.lastPage < knownMax) return record; const parsedUrl = record.url ? parseThreadHistoryUrl(record.url) : null; const page = Math.max(1, Number(record.page || (parsedUrl && parsedUrl.page)) || 1); const boundedPage = Math.min(page, bounds.lastPage); const existingUrlPage = parsedUrl && parsedUrl.page || 0; const next = Object.assign({}, record, { page: boundedPage, maxVisitedPage: Math.min(Math.max(Number(record.maxVisitedPage) || boundedPage, boundedPage), bounds.lastPage), lastKnownPage: Math.max(Number(record.lastKnownPage) || 0, bounds.lastPage) }); if (page > bounds.lastPage || existingUrlPage > bounds.lastPage) { next.url = buildThreadHistoryPageUrl(next.mode, next.threadId, boundedPage); } return next; } function getElementTextPreserveZeroWidth(el) { return el ? String(el.textContent || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') : ''; } function getVisibleTextForHistory(text) { return String(text || '').replace(ZERO_WIDTH_RE, '').replace(/[\s\u00a0]+/g, ''); } function trimThreadHistoryContentText(text) { return String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/^[ \t\r\n]+/, '').replace(/[ \t\r\n]+$/, ''); // 只去除ASCII空白(空格/Tab/换行),保留全角空格/零宽空格等用户输入的Unicode空白 } function sanitizeThreadHistoryInlineStyle(styleValue) { const safeRules = []; String(styleValue || '').split(';').forEach(rule => { const separator = rule.indexOf(':'); if (separator === -1) return; const name = rule.slice(0, separator).trim().toLowerCase(); const value = rule.slice(separator + 1).trim(); if (!/^(color|background-color|text-decoration|font-weight)$/.test(name) && !/^--darkreader-inline-(?:color|bgcolor)$/.test(name)) return; if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) return; safeRules.push(`${name}: ${value}`); }); return safeRules.join('; '); } function sanitizeThreadHistoryContentUrl(urlValue) { try { const url = new URL(urlValue, location.origin); if (!/^https?:$/.test(url.protocol)) return ''; return url.href; } catch (e) { return ''; } } function escapeThreadHistoryHtml(text) { return String(text || '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function cleanThreadHistoryContentWhitespace(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); if (!nodes.length) return; // 首尾文本节点去空白 — 只清理ASCII空白(空格/Tab/换行),保留用户输入的全角空格/零宽空格等Unicode空白 nodes[0].nodeValue = String(nodes[0].nodeValue || '').replace(/^[ \t\r\n]+/, ''); const last = nodes[nodes.length - 1]; last.nodeValue = String(last.nodeValue || '').replace(/[ \t\r\n]+$/, ''); } function normalizeThreadHistoryContentWhitespace(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); nodes.forEach(node => { const prev = node.previousSibling; const next = node.nextSibling; let value = String(node.nodeValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); // value = value.replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, '\n\n'); // 连续空行压缩 — 已禁用,保留原始空行 if (prev && prev.nodeType === Node.ELEMENT_NODE && prev.tagName === 'BR') value = value.replace(/^[ \t\r\n]+/, ''); // BR后空白清理 — 只清理ASCII空白,保留全角空格/零宽空格等用户输入的Unicode空白 if (next && next.nodeType === Node.ELEMENT_NODE && next.tagName === 'BR') value = value.replace(/[ \t]+$/, ''); // BR前空白清理 — 保留:清理HTML源码格式化产生的空格 node.nodeValue = value; }); cleanThreadHistoryContentWhitespace(root); } function removeThreadHistoryTrailingBreaks(root) { while (root && root.lastChild) { const node = root.lastChild; if (node.nodeType === Node.ELEMENT_NODE) { removeThreadHistoryTrailingBreaks(node); } if (node.nodeType === Node.TEXT_NODE && !String(node.nodeValue || '').trim()) { node.remove(); continue; } if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') { node.remove(); continue; } if (isEmptyThreadHistoryInlineElement(node)) { node.remove(); continue; } break; } } function isEmptyThreadHistoryInlineElement(node) { if (!node || node.nodeType !== Node.ELEMENT_NODE) return false; if (!/^(A|SPAN|FONT|B|STRONG|I|EM|U|S|DEL|CODE|SUB|SUP)$/.test(node.tagName)) return false; return !String(node.textContent || '').trim() && !node.querySelector('img, video, audio, canvas, svg'); } function limitThreadHistoryContentText(root, limit) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); let remaining = Math.max(0, Number(limit) || 0); let truncated = false; for (const node of nodes) { if (truncated || remaining <= 0) { node.nodeValue = ''; continue; } const value = String(node.nodeValue || ''); if (value.length > remaining) { node.nodeValue = value.slice(0, remaining).replace(/\s+$/, ''); pruneAfterThreadHistoryTextNode(root, node); truncated = true; remaining = 0; } else { remaining -= value.length; } } cleanThreadHistoryContentWhitespace(root); removeThreadHistoryTrailingBreaks(root); } function pruneAfterThreadHistoryTextNode(root, textNode) { let current = textNode; while (current && current.parentNode && current !== root) { while (current.nextSibling) current.nextSibling.remove(); current = current.parentNode; } if (current === root) removeThreadHistoryTrailingBreaks(root); } function isThreadHistoryContentTruncated(text) { return String(text || '').length > THREAD_HISTORY_EXCERPT_LIMIT; } function appendThreadHistoryTruncationMarker(contentEl) { if (!contentEl) return; removeThreadHistoryTrailingBreaks(contentEl); contentEl.appendChild(document.createTextNode('……')); } function sanitizeThreadHistoryInlineHtml(sourceEl) { if (!sourceEl) return ''; const clone = sourceEl.cloneNode(true); clone.querySelectorAll('script, style, template, iframe, object, embed, svg, math').forEach(el => el.remove()); const allowedTags = new Set(['SPAN', 'FONT', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'DEL', 'SUB', 'SUP']); Array.from(clone.querySelectorAll('*')).forEach(el => { if (!allowedTags.has(el.tagName)) { el.replaceWith(...Array.from(el.childNodes)); return; } Array.from(el.attributes).forEach(attr => { const name = attr.name.toLowerCase(); const value = attr.value || ''; if (name === 'style') { const safeStyle = sanitizeThreadHistoryInlineStyle(value); if (safeStyle) el.setAttribute('style', safeStyle); else el.removeAttribute(attr.name); return; } if (el.tagName === 'FONT' && name === 'color') return; if (name === 'class') return; if (name === 'data-darkreader-inline-color' || name === 'data-darkreader-inline-bgcolor') return; el.removeAttribute(attr.name); }); }); cleanThreadHistoryContentWhitespace(clone); return clone.innerHTML.replace(/^[ \t\r\n]+/, '').replace(/[ \t\r\n]+$/, ''); // 只去除ASCII空白,保留全角空格/零宽空格等用户输入的Unicode空白 } function extractThreadHistoryCookieId(cookieEl, fallbackText) { if (cookieEl) { const font = cookieEl.querySelector('font'); if (font) { for (const node of font.childNodes) { if (node.nodeType === Node.TEXT_NODE) { const value = String(node.nodeValue || '').trim(); if (value) return value; } } } } const value = (String(fallbackText || '').split(':')[1] || fallbackText || '').trim(); const match = String(value).match(/[A-Za-z0-9]{3,8}/); return match ? match[0] : value; } function buildThreadHistoryLegacyCookieHtml(cookieId) { const value = String(cookieId || '').trim(); const match = value.match(/^([A-Za-z0-9]{3,8})(.+)$/); if (!match || !match[2].trim()) return ''; const id = escapeThreadHistoryHtml(match[1]); const badge = escapeThreadHistoryHtml(match[2].trim()); return `ID:<font color="red">${id}<sub style="color: darkorange; font-weight: bold;">${badge}</sub></font>`; } function getThreadHistoryCookieMarkId(item) { const value = String(item && item.cookieId || '').trim(); const match = value.match(/^([A-Za-z0-9]{3,8})/); return match ? match[1] : value; } function sanitizeThreadHistoryContentHtml(contentEl) { if (!contentEl) return ''; const clone = contentEl.cloneNode(true); clone.querySelectorAll('script, style, template, iframe, object, embed, svg, math').forEach(el => el.remove()); const allowedTags = new Set(['A', 'BR', 'SPAN', 'FONT', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'DEL', 'CODE', 'PRE', 'SUB', 'SUP']); Array.from(clone.querySelectorAll('*')).forEach(el => { if (!allowedTags.has(el.tagName)) { el.replaceWith(...Array.from(el.childNodes)); return; } Array.from(el.attributes).forEach(attr => { const name = attr.name.toLowerCase(); const value = attr.value || ''; if (name.startsWith('on') || name === 'id' || (name.startsWith('data-') && name !== 'data-darkreader-inline-color' && name !== 'data-darkreader-inline-bgcolor')) { el.removeAttribute(attr.name); return; } if (name === 'style') { const safeStyle = sanitizeThreadHistoryInlineStyle(value); if (safeStyle) el.setAttribute('style', safeStyle); else el.removeAttribute(attr.name); return; } if (el.tagName === 'A' && name === 'href') { const safeHref = sanitizeThreadHistoryContentUrl(value); if (safeHref) { el.setAttribute('href', safeHref); el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener'); } else { el.removeAttribute(attr.name); } return; } if (el.tagName === 'FONT' && name === 'color') return; if (name !== 'class' && name !== 'title' && name !== 'target' && name !== 'rel') el.removeAttribute(attr.name); }); }); normalizeThreadHistoryContentWhitespace(clone); return clone.innerHTML.replace(/^[ \t\r\n]+/, '').replace(/[ \t\r\n]+$/, ''); // 只去除ASCII空白,保留全角空格/零宽空格等用户输入的Unicode空白 } function normalizeThreadHistoryImageFile(urlValue) { if (!urlValue) return ''; try { const url = new URL(urlValue, location.origin); const match = url.pathname.match(/\/(?:image|thumb)\/([^?#]+)/); return match ? decodeURIComponent(match[1]) : ''; } catch (e) { const match = String(urlValue).match(/\/(?:image|thumb)\/([^?#]+)/); return match ? decodeURIComponent(match[1]) : ''; } } const THREAD_HISTORY_IMAGE_FILE_CONTRACT_EXAMPLE = '2024-12-10/6757ea866e1aa.png'; function extractThreadHistoryImageFile(mainEl) { if (!mainEl) return ''; const anchor = mainEl.querySelector('.h-threads-img-a[href]'); const img = mainEl.querySelector('.h-threads-img-a img, img.h-threads-img'); const sources = [ anchor && anchor.getAttribute('href'), img && img.dataset && img.dataset.xdexHdSrc, img && img.dataset && img.dataset.xdexThumbSrc, img && img.dataset && img.dataset.src, img && img.getAttribute && img.getAttribute('src') ]; for (const source of sources) { const imageFile = normalizeThreadHistoryImageFile(source); if (imageFile) return imageFile; } return ''; } function isThreadHistoryMainCandidate(el) { return !!el && !el.closest('.h-preview-box') && !!el.querySelector('.h-threads-content'); } function findThreadHistoryMainElement(root, parsed) { const scope = root || document; const primary = scope.querySelector('.h-threads-list .h-threads-item-main'); if (isThreadHistoryMainCandidate(primary)) return primary; const mains = Array.from(scope.querySelectorAll('.h-threads-item-main')); return mains.find(isThreadHistoryMainCandidate) || null; } function extractThreadHistoryRecord(root) { const parsed = parseThreadHistoryUrl(location.href); if (!parsed) { logThreadHistory('skip record: unsupported url', { url: location.href }); return null; } const mainEl = findThreadHistoryMainElement(root, parsed); if (!mainEl) { logThreadHistory('skip record: missing h-threads-item-main', { url: location.href, parsed }); return null; } const contentEl = mainEl.querySelector('.h-threads-content'); const rawContent = getElementTextPreserveZeroWidth(contentEl); const contentText = trimThreadHistoryContentText(rawContent); const contentTruncated = isThreadHistoryContentTruncated(contentText); const contentHtml = sanitizeThreadHistoryContentHtml(contentEl); const hasZeroWidth = ZERO_WIDTH_RE.test(rawContent); const hasVisibleText = !!getVisibleTextForHistory(rawContent); const hasWhitespaceOnly = !hasVisibleText && /^[\s\u00a0]*$/.test(rawContent.replace(ZERO_WIDTH_RE, '')) && rawContent.length > 0; const imageFile = extractThreadHistoryImageFile(mainEl); const title = getElementTextPreserveZeroWidth(mainEl.querySelector('.h-threads-info-title')).trim(); const author = getElementTextPreserveZeroWidth(mainEl.querySelector('.h-threads-info-email')).trim(); const cookieEl = mainEl.querySelector('.h-threads-info-uid'); const cookieText = getElementTextPreserveZeroWidth(cookieEl).trim(); const cookieId = extractThreadHistoryCookieId(cookieEl, cookieText); const cookieHtml = sanitizeThreadHistoryInlineHtml(cookieEl); const createdAtEl = mainEl.querySelector('.h-threads-info-createdat, .h-threads-info time'); const createdAt = String(createdAtEl && (createdAtEl.getAttribute('title') || createdAtEl.getAttribute('datetime')) || getElementTextPreserveZeroWidth(createdAtEl)).trim(); const tipsEl = mainEl.querySelector('.h-threads-tips'); const sageHtml = (tipsEl && /SAGE/i.test(tipsEl.textContent || '')) ? '<i class="uk-icon-thumbs-down"></i> 本串已经被SAGE' : ''; return { key: getThreadHistoryKey(parsed.mode, parsed.threadId), mode: parsed.mode, threadId: parsed.threadId, page: parsed.page, url: parsed.url, title, author, cookieId, cookieHtml, createdAt, contentText, contentHtml, contentTruncated, excerpt: contentText.slice(0, THREAD_HISTORY_EXCERPT_LIMIT), imageFile, contentFlags: { hasVisibleText, hasWhitespaceOnly, hasZeroWidth }, sageHtml, lastScrollY: Math.max(0, Math.floor(window.scrollY || 0)) }; } // 索引指纹:覆盖 searchText 与全部布尔谓词的来源字段;仅短标量拼接,远廉于重建 searchText function threadHistoryIndexFingerprint(item) { const flags = (item && item.contentFlags) || {}; return [ item && item.threadId, item && item.mode, item && item.title, item && item.author, item && item.cookieId, String(item && (item.contentText || item.excerpt) || '').length, item && item.imageFile, flags.hasZeroWidth ? 1 : 0, flags.hasVisibleText ? 1 : 0, flags.hasWhitespaceOnly ? 1 : 0, item && item.sageHtml ? 1 : 0, Number(item && item.lastVisitedAt) || 0 ].join('\u0001'); } function buildThreadHistoryIndexEntry(item) { const contentFlags = item && item.contentFlags ? item.contentFlags : {}; const imageFile = String(item && item.imageFile || ''); const titleText = String(item && item.title || '').toLowerCase(); const authorText = String(item && item.author || '').toLowerCase(); const cookieIdText = String(item && item.cookieId || '').toLowerCase(); const excerptText = String(item && (item.contentText || item.excerpt) || '').toLowerCase(); const threadIdText = String(item && item.threadId || ''); return { fp: threadHistoryIndexFingerprint(item), searchText: [threadIdText, titleText, authorText, cookieIdText, excerptText].join(' ').toLowerCase(), threadIdText, titleText, authorText, cookieIdText, excerptText, mode: item && item.mode === 'po' ? 'po' : 'normal', hasImage: !!imageFile, isGif: /\.gif(?:$|[?#])/i.test(imageFile), hasZeroWidth: !!contentFlags.hasZeroWidth, hasVisibleText: !!contentFlags.hasVisibleText, hasWhitespaceOnly: !!contentFlags.hasWhitespaceOnly, isSage: !!(item && item.sageHtml), lastVisitedAt: Number(item && item.lastVisitedAt) || 0 }; } function upsertThreadHistoryRecord(nextRecord, options = {}) { if (!nextRecord || !nextRecord.threadId || !nextRecord.mode) return getThreadHistoryStore(); const now = Date.now(); const countVisit = options.countVisit !== false; const touchVisitedAt = countVisit || options.touchVisitedAt === true; const store = getThreadHistoryStore(); // 写前校验: 以存量进度为信任下限。过时分页快照(lastPage < 已到过的最远页)在 bounds 闸门被整条拒绝, // 不会再把 page/maxVisitedPage/url 打回旧页码 const key = nextRecord.key || getThreadHistoryKey(nextRecord.mode, nextRecord.threadId); const old = store.items[key] || {}; const knownMax = Math.max(Number(old.maxVisitedPage) || 0, Number(old.page) || 0); nextRecord = applyThreadHistoryPageBounds(nextRecord, document, knownMax); // maxVisitedPage 单调只升不降(lastKnownPage 钳制在闸门之后仅剩抬升作用) const maxVisitedPage = Math.max(Number(old.maxVisitedPage) || 1, Number(nextRecord.page) || 1); const mergedBase = Object.assign({}, old, nextRecord); const merged = Object.assign({}, applyThreadHistoryPageBounds(mergedBase, document, knownMax), { key, firstVisitedAt: old.firstVisitedAt || now, lastVisitedAt: touchVisitedAt ? now : (Number(old.lastVisitedAt) || now), visitCount: (Number(old.visitCount) || 0) + (countVisit ? 1 : 0), maxVisitedPage, cookieHtml: nextRecord.cookieHtml || old.cookieHtml || '' }); store.items[key] = merged; store.order = [key].concat((store.order || []).filter(itemKey => itemKey !== key)); const saved = setThreadHistoryStore(store); logThreadHistory('record saved', { key, total: saved.order.length, countVisit, reason: options.reason || '', record: JSON.parse(JSON.stringify({ page: merged.page, maxVisitedPage: merged.maxVisitedPage, lastKnownPage: merged.lastKnownPage, url: merged.url, visitCount: merged.visitCount })) }); // 同步常用串菜单中对应串的链接 try { if (typeof syncFavoriteThreadsLinks === 'function') syncFavoriteThreadsLinks(); } catch (e) {} return saved; } function touchThreadHistoryCurrentLocation(options = {}) { if (!isThreadHistoryEnabledNow()) return getThreadHistoryStore(); // 浏览历史已关闭:不更新位置 const parsed = parseThreadHistoryUrl(options.url || location.href); if (!parsed) return getThreadHistoryStore(); const key = getThreadHistoryKey(parsed.mode, parsed.threadId); const store = getThreadHistoryStore(); const item = store.items[key]; if (!item) return store; const now = Date.now(); // 写前校验: 以存量进度为信任下限, 过时分页快照不得降级写入; lastKnownPage/maxVisitedPage 单调只升 const knownMax = Math.max(Number(item.maxVisitedPage) || 0, Number(item.page) || 0); // page = 最近查看页, 如实记录当前所在页(允许主动回退到更低页码); maxVisitedPage 不受回翻影响 const bounded = applyThreadHistoryPageBounds(Object.assign({}, item, { page: Number(options.page || parsed.page) || 1, url: options.url || parsed.url }), document, knownMax); item.page = bounded.page; item.url = bounded.url || item.url; item.maxVisitedPage = Math.max(Number(item.maxVisitedPage) || 1, Number(item.page) || 1); if (bounded.lastKnownPage && Number(bounded.lastKnownPage) > (Number(item.lastKnownPage) || 0)) item.lastKnownPage = bounded.lastKnownPage; item.lastScrollY = Math.max(0, Math.floor(window.scrollY || 0)); if (options.touchVisitedAt) item.lastVisitedAt = now; store.index[key] = buildThreadHistoryIndexEntry(item); store.order = [key].concat((store.order || []).filter(itemKey => itemKey !== key)); const saved = setThreadHistoryStore(store); logThreadHistory('location touched', { key, reason: options.reason || '', page: item.page, url: item.url, maxVisitedPage: item.maxVisitedPage, touchVisitedAt: !!options.touchVisitedAt }); // 同步常用串菜单中对应串的链接 try { if (typeof syncFavoriteThreadsLinks === 'function') syncFavoriteThreadsLinks(); } catch (e) {} return saved; } function recordThreadHistoryProgress(options = {}) { if (!isThreadHistoryEnabledNow()) return getThreadHistoryStore(); // 浏览历史已关闭:不记录翻页进度 const parsed = parseThreadHistoryUrl(options.url || location.href); if (!parsed) return getThreadHistoryStore(); const record = extractThreadHistoryRecord(document) || { key: getThreadHistoryKey(parsed.mode, parsed.threadId), mode: parsed.mode, threadId: parsed.threadId }; record.key = getThreadHistoryKey(parsed.mode, parsed.threadId); record.mode = parsed.mode; record.threadId = parsed.threadId; record.page = Math.max(Number(record.page) || 1, Number(options.page || parsed.page) || 1); record.url = options.url || parsed.url; record.lastScrollY = Math.max(0, Math.floor(window.scrollY || 0)); return upsertThreadHistoryRecord(record, { countVisit: false, touchVisitedAt: options.touchVisitedAt === true, reason: options.reason || 'progress' }); } function updateThreadHistoryScrollPosition() { touchThreadHistoryCurrentLocation({ reason: 'scroll-position' }); } function deleteThreadHistoryItem(key) { const store = getThreadHistoryStore(); // 软删除(分家): 原记录整体搬入墓碑快照, 主列表移除; 30 天内可恢复, 到期自动降级 const item = store.items[key]; if (item) { store.tombstones = store.tombstones || {}; const prev = store.tombstones[key]; store.tombstones[key] = { deletedAt: Date.now(), origin: 'local', purged: false, revivedAt: null, record: Object.assign({}, item) }; void prev; try { const bl = GM_getValue('xdex_webdav_history_baselines', null); if (bl && bl.threadHistory && (key in bl.threadHistory)) { delete bl.threadHistory[key]; GM_setValue('xdex_webdav_history_baselines', bl); } } catch (e) {} } delete store.items[key]; delete store.index[key]; store.order = (store.order || []).filter(itemKey => itemKey !== key); return setThreadHistoryStore(store); } function restoreThreadHistoryFromTombstone(key) { const store = getThreadHistoryStore(); const tomb = (store.tombstones || {})[key]; if (!tomb || tomb.purged || tomb.revivedAt) return false; const record = tomb.record || null; const shadow = store.items[key] || null; // 删除后新段(如有) let merged = null; if (record && shadow) { const recNewer = (Number(record.lastVisitedAt) || 0) >= (Number(shadow.lastVisitedAt) || 0); merged = Object.assign({}, shadow, record, { firstVisitedAt: Math.min(Number(record.firstVisitedAt) || Infinity, Number(shadow.firstVisitedAt) || Infinity), lastVisitedAt: Math.max(Number(record.lastVisitedAt) || 0, Number(shadow.lastVisitedAt) || 0), page: Math.max(Number(record.page) || 0, Number(shadow.page) || 0), maxVisitedPage: Math.max(Number(record.maxVisitedPage) || 0, Number(shadow.maxVisitedPage) || 0), visitCount: (Number(record.visitCount) || 0) + (Number(shadow.visitCount) || 0), title: recNewer ? (record.title || shadow.title) : (shadow.title || record.title), name: recNewer ? (record.name || shadow.name) : (shadow.name || record.name) }); } else { merged = record ? Object.assign({}, record) : (shadow ? Object.assign({}, shadow) : null); } if (merged) { store.items[key] = merged; store.index[key] = buildThreadHistoryIndexEntry(merged); if (!(store.order || []).includes(key)) store.order.push(key); } // 墓碑转为 revivedAt 传播标记: 对端据此执行同样的恢复合并 store.tombstones = store.tombstones || {}; store.tombstones[key] = { deletedAt: Number(tomb.deletedAt) || Date.now(), origin: tomb.origin || 'local', purged: false, revivedAt: Date.now() }; // 基线重置为恢复后的全局值, 防止下轮 delta 虚算 if (merged) { try { const bl = GM_getValue('xdex_webdav_history_baselines', null) || {}; bl.threadHistory = bl.threadHistory || {}; bl.threadHistory[key] = { count: Number(merged.visitCount) || 0, at: Date.now() }; GM_setValue('xdex_webdav_history_baselines', bl); } catch (e) {} } else { deleteThreadHistoryBaselineEntry(key); } return setThreadHistoryStore(store); } function purgeThreadHistoryTombstone(key) { const store = getThreadHistoryStore(); const tomb = (store.tombstones || {})[key]; if (!tomb) return false; // 立即删除: 快照与新段一并清除, 仅留压制标记(purged), 防止对端旧数据复活 delete store.items[key]; delete store.index[key]; store.order = (store.order || []).filter(itemKey => itemKey !== key); store.tombstones[key] = { deletedAt: Number(tomb.deletedAt) || Date.now(), origin: tomb.origin || 'local', purged: true }; deleteThreadHistoryBaselineEntry(key); return setThreadHistoryStore(store); } function purgeAllThreadHistoryTombstones() { const store = getThreadHistoryStore(); const tombs = store.tombstones || {}; const now = Date.now(); Object.keys(tombs).forEach((key) => { if (tombs[key].purged) return; delete store.items[key]; delete store.index[key]; tombs[key] = { deletedAt: Number(tombs[key].deletedAt) || now, origin: tombs[key].origin || 'local', purged: true }; deleteThreadHistoryBaselineEntry(key); }); store.order = (store.order || []).filter(k => !tombs[k] || tombs[k].purged); return setThreadHistoryStore(store); } function deleteThreadHistoryBaselineEntry(key) { try { const bl = GM_getValue('xdex_webdav_history_baselines', null); if (!bl || !bl.threadHistory || !(key in bl.threadHistory)) return; delete bl.threadHistory[key]; GM_setValue('xdex_webdav_history_baselines', bl); } catch (e) {} } function clearThreadHistory() { // 批量软删除: 全部条目移入回收站(逐条墓碑), 30 天后自动彻底清除; 期间可恢复 const store = getThreadHistoryStore(); const now = Date.now(); store.tombstones = store.tombstones || {}; Object.keys(store.items).forEach((key) => { if (store.tombstones[key] && !store.tombstones[key].purged && !store.tombstones[key].revivedAt) return; store.tombstones[key] = { deletedAt: now, origin: 'local', purged: false, revivedAt: null, record: Object.assign({}, store.items[key]) }; deleteThreadHistoryBaselineEntry(key); }); store.items = {}; store.index = {}; store.order = []; return setThreadHistoryStore(store); } function parseThreadHistorySearchQuery(query) { const filters = { mode: '', hasImage: false, isGif: false, hasZeroWidth: false, isSage: false }; const tokens = []; String(query || '').toLowerCase().split(/\s+/).filter(Boolean).forEach(token => { if (token === 'mode:po') filters.mode = 'po'; else if (token === 'mode:normal') filters.mode = 'normal'; else if (token === 'has:image') filters.hasImage = true; else if (token === 'has:gif') filters.isGif = true; else if (token === 'has:zwsp' || token === 'has:zerowidth') filters.hasZeroWidth = true; else if (token === 'has:sage') filters.isSage = true; else tokens.push(token); }); return { filters, tokens }; } function scoreThreadHistoryIndexEntry(entry, numericTokens) { let score = Number(entry.lastVisitedAt) || 0; for (let i = 0; i < numericTokens.length; i++) { const token = numericTokens[i]; if (entry.threadIdText === token) score += 1000000000000000; else if (entry.threadIdText.includes(token)) score += 500000000000000; } return score; } function getThreadHistorySortValue(item, field) { if (!item) return 0; if (field === 'visitCount') return Number(item.visitCount) || 0; if (field === 'maxVisitedPage') return Number(item.maxVisitedPage || item.page) || 0; return Number(item.lastVisitedAt) || 0; } function compareThreadHistoryResults(a, b, sortMode, numericTokens) { const itemA = a.item || {}; const itemB = b.item || {}; if (sortMode === 'last-asc') return getThreadHistorySortValue(itemA, 'lastVisitedAt') - getThreadHistorySortValue(itemB, 'lastVisitedAt'); if (sortMode === 'visits-desc') return getThreadHistorySortValue(itemB, 'visitCount') - getThreadHistorySortValue(itemA, 'visitCount') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt'); if (sortMode === 'visits-asc') return getThreadHistorySortValue(itemA, 'visitCount') - getThreadHistorySortValue(itemB, 'visitCount') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt'); if (sortMode === 'page-desc') return getThreadHistorySortValue(itemB, 'maxVisitedPage') - getThreadHistorySortValue(itemA, 'maxVisitedPage') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt'); // 分数已在检索期预计算;兜底按需再算 const scoreA = a.score != null ? a.score : scoreThreadHistoryIndexEntry(a.index, numericTokens || []); const scoreB = b.score != null ? b.score : scoreThreadHistoryIndexEntry(b.index, numericTokens || []); return scoreB - scoreA; } function searchThreadHistory(query, storeInput, sortMode) { // 默认走记忆化缓存:仅显式传入 storeInput 时才归一化,避免每次检索重复归一化 + 重复排序 const store = storeInput ? normalizeThreadHistoryStore(storeInput) : getThreadHistoryStore(); const { filters, tokens } = parseThreadHistorySearchQuery(query); // 数字 token 判定外提一次:原先在比较器内每次比较都跑正则(约 2·m·log m·tokens 次) const numericTokens = tokens.filter(token => /^\d{1,8}$/.test(token)); const useScoreSort = !sortMode || sortMode === 'last-desc'; return (store.order || []) .filter(key => { const entry = store.index[key]; if (!entry || !store.items[key]) return false; // 回收站压制: 存在未复活墓碑的条目不在主列表显示(删除后新段同样隐藏, 恢复后随 revivedAt 解除) const tomb = store.tombstones && store.tombstones[key]; // 仅"无删除后新段"的纯墓碑才隐藏; 再次浏览会重建 items 影子条目, 带回收站标识重新显示 if (tomb && !tomb.revivedAt && !store.items[key]) return false; if (filters.mode && entry.mode !== filters.mode) return false; if (filters.hasImage && !entry.hasImage) return false; if (filters.isGif && !entry.isGif) return false; if (filters.hasZeroWidth && !entry.hasZeroWidth) return false; if (filters.isSage && !entry.isSage) return false; return tokens.every(token => entry.searchText.includes(token)); }) .map(key => { const index = store.index[key]; return { key, item: store.items[key], index, score: useScoreSort ? scoreThreadHistoryIndexEntry(index, numericTokens) : 0 }; }) .sort((a, b) => compareThreadHistoryResults(a, b, sortMode || 'last-desc', numericTokens)); } let threadHistoryScrollTrackingInstalled = false; function installThreadHistoryScrollTracking() { if (threadHistoryScrollTrackingInstalled) return; threadHistoryScrollTrackingInstalled = true; let scrollTimer = 0; window.addEventListener('scroll', () => { if (scrollTimer) return; scrollTimer = setTimeout(() => { scrollTimer = 0; updateThreadHistoryScrollPosition(); }, 1200); }, { passive: true }); window.addEventListener('pagehide', updateThreadHistoryScrollPosition, { passive: true }); } function isThreadHistoryPageActive() { return document.visibilityState === 'visible' && (typeof document.hasFocus !== 'function' || document.hasFocus()); } function cancelThreadHistoryDwellTimer(resetSession) { if (threadHistoryDwellTimer) clearTimeout(threadHistoryDwellTimer); threadHistoryDwellTimer = 0; threadHistoryVisibleSince = 0; if (resetSession) threadHistoryVisibleSessionCounted = false; } function scheduleThreadHistoryReactivationVisit(source) { if (!parseThreadHistoryUrl(location.href)) return; if (!isThreadHistoryPageActive()) { cancelThreadHistoryDwellTimer(document.visibilityState !== 'visible'); return; } if (threadHistoryVisibleSessionCounted || threadHistoryDwellTimer) return; threadHistoryVisibleSince = Date.now(); updateThreadHistoryDebugState({ lastDwell: { source, status: 'scheduled', threshold: THREAD_HISTORY_REVISIT_DWELL_MS, at: new Date().toISOString() } }); threadHistoryDwellTimer = setTimeout(() => { threadHistoryDwellTimer = 0; if (!threadHistoryVisibleSince || !isThreadHistoryPageActive()) return; if (Date.now() - threadHistoryVisibleSince < THREAD_HISTORY_REVISIT_DWELL_MS) return; threadHistoryVisibleSessionCounted = true; updateThreadHistoryDebugState({ lastDwell: { source, status: 'counted', threshold: THREAD_HISTORY_REVISIT_DWELL_MS, at: new Date().toISOString() } }); recordCurrentThreadHistory(0, { reason: 'reactivation-dwell', countVisit: true }); }, THREAD_HISTORY_REVISIT_DWELL_MS); } function installThreadHistoryReactivationTracking(initialCounted) { if (threadHistoryReactivationTrackingInstalled) return; threadHistoryReactivationTrackingInstalled = true; threadHistoryVisibleSessionCounted = !!initialCounted && document.visibilityState === 'visible'; document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { scheduleThreadHistoryReactivationVisit('visibilitychange'); // ★ 补跑全量加宽(QwQNT 时序自愈):初始 pass 可能命中 h-active 静默跳过 / // offsetWidth=0 RO 自愈 / targetWidth<=0 静默返回,这些分支失败时无锁无兜底; // QwQNT 宿主在标签切换时会派发正确的 visibilitychange,此处即自愈触发器。 // __imageWidthExpanded 锁保证已加宽者 O(1) 跳过;函数未初始化时可选链静默跳过 try { enableHDImageAndLayoutFix.expandReplyMainWidths?.(document); } catch (e) {} } else { cancelThreadHistoryDwellTimer(true); } }, { passive: true }); window.addEventListener('focus', () => scheduleThreadHistoryReactivationVisit('focus'), { passive: true }); window.addEventListener('blur', () => cancelThreadHistoryDwellTimer(false), { passive: true }); window.addEventListener('pagehide', () => cancelThreadHistoryDwellTimer(true), { passive: true }); } // ★ 历史功能开关读取(即时生效:优先 SettingPanel.state,未初始化时回退 defaults) function isThreadHistoryEnabledNow() { try { const sp = typeof SettingPanel !== 'undefined' ? SettingPanel : null; if (!sp) return true; const state = sp.state || {}; if (typeof state.enableThreadHistory === 'boolean') return state.enableThreadHistory; const defaults = sp.defaults || {}; return defaults.enableThreadHistory !== false; } catch (e) { return true; } } function isPostHistoryEnabledNow() { try { const sp = typeof SettingPanel !== 'undefined' ? SettingPanel : null; if (!sp) return true; const state = sp.state || {}; if (typeof state.enablePostHistory === 'boolean') return state.enablePostHistory; const defaults = sp.defaults || {}; return defaults.enablePostHistory !== false; } catch (e) { return true; } } function recordCurrentThreadHistory(attempt = 0, options = {}) { if (!isThreadHistoryEnabledNow()) return; // 浏览历史已关闭:不再自动记录(已有数据保留) const record = extractThreadHistoryRecord(document); if (!record) { const parsed = parseThreadHistoryUrl(location.href); const missingMain = !!parsed && !findThreadHistoryMainElement(document, parsed); updateThreadHistoryDebugState({ lastRecord: { status: parsed ? (missingMain ? 'missing-main' : 'extract-failed') : 'unsupported-url', attempt, parsed, at: new Date().toISOString(), readyState: document.readyState, mainCount: document.querySelectorAll('.h-threads-item-main').length, listCount: document.querySelectorAll('.h-threads-list').length } }); if (missingMain && attempt < THREAD_HISTORY_RECORD_RETRY_LIMIT) { logThreadHistory('retry record: waiting for h-threads-item-main', { attempt: attempt + 1, limit: THREAD_HISTORY_RECORD_RETRY_LIMIT, delay: THREAD_HISTORY_RECORD_RETRY_DELAY, readyState: document.readyState }); setTimeout(() => recordCurrentThreadHistory(attempt + 1), THREAD_HISTORY_RECORD_RETRY_DELAY); } return; } upsertThreadHistoryRecord(record, Object.assign({ reason: 'initial-load', countVisit: true }, options)); updateThreadHistoryDebugState({ lastRecord: { status: 'saved', attempt, reason: options.reason || 'initial-load', record, at: new Date().toISOString() } }); installThreadHistoryScrollTracking(); } function formatThreadHistoryTime(ts) { if (!ts) return ''; const d = new Date(ts); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); return `${y}-${m}-${day} ${hh}:${mm}`; } function formatRelativeTimeMachineTime(ts) { if (!ts) return ''; const d = new Date(ts); if (Number.isNaN(d.getTime())) return ''; const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const weekday = '日一二三四五六'[d.getDay()]; const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); return `${y}-${m}-${day}(${weekday})${hh}:${mm}:${ss}`; } function buildThreadHistoryImageUrl(imageFile, full) { if (!imageFile) return ''; const path = /\.gif$/i.test(imageFile) || full ? 'image' : 'thumb'; const encodedFile = String(imageFile).split('/').map(encodeURIComponent).join('/'); return `https://image.nmb.best/${path}/${encodedFile}`; } function buildThreadHistoryItemUrl(item) { // 消费一致性: 不信任持久化的 item.url(可能被旧快照/裸展开污染, 出现 page=97/url=?page=86 的矛盾)。 // 链接指向最近查看页(item.page, 允许低于 maxVisitedPage 的主动回翻); 仅当无法重建时才回退 item.url if (item && item.page && item.threadId) { return buildThreadHistoryPageUrl(item.mode, item.threadId, item.page); } if (item && item.url) return item.url; const threadId = item && item.threadId ? item.threadId : ''; const page = item && item.page ? item.page : 1; return buildThreadHistoryPageUrl(item && item.mode, threadId, page); } function buildHistorySearchHelpMark(title) { const mark = document.createElement('span'); mark.className = 'xdex-history-search-help'; mark.textContent = '?'; mark.title = title || ''; mark.style.textDecoration = 'underline'; mark.style.cursor = 'help'; mark.style.whiteSpace = 'nowrap'; mark.setAttribute('aria-label', title || '高级检索说明'); return mark; } function getLatestThreadHistoryUrl(threadId) { const tid = String(threadId || '').trim(); if (!isValidThreadId(tid)) return ''; const store = getThreadHistoryStore(); // 常用串/浏览历史联动:优先普通模式,没有再回退只看 PO;都没有返回空(调用方回退普通第 1 页) const normal = store.items[getThreadHistoryKey('normal', tid)]; if (normal) return buildThreadHistoryItemUrl(normal); const po = store.items[getThreadHistoryKey('po', tid)]; return po ? buildThreadHistoryItemUrl(po) : ''; } function appendThreadHistoryText(parent, tagName, className, text) { const el = document.createElement(tagName); if (className) el.className = className; el.textContent = text || ''; parent.appendChild(el); return el; } function appendThreadHistoryInfoText(parent, className, text) { const value = String(text || '').trim(); if (!value) return null; return appendThreadHistoryText(parent, 'span', className, value); } function shouldRenderThreadHistoryTitle(title) { const value = String(title || '').trim(); return !!value && value !== '无标题'; } function shouldRenderThreadHistoryAuthor(author) { const value = String(author || '').trim(); return !!value && value !== '无名氏'; } function buildThreadHistoryItemElement(result) { const item = result.item || {}; const wrapper = document.createElement('div'); wrapper.className = 'xdex-history-item'; wrapper.dataset.historyKey = result.key; const main = document.createElement('div'); main.className = 'h-threads-item-main'; wrapper.appendChild(main); const info = document.createElement('div'); info.className = 'h-threads-info xdex-history-info'; main.appendChild(info); const infoMain = document.createElement('span'); infoMain.className = 'xdex-history-info-main'; info.appendChild(infoMain); if (shouldRenderThreadHistoryTitle(item.title)) appendThreadHistoryInfoText(infoMain, 'h-threads-info-title', item.title); if (shouldRenderThreadHistoryAuthor(item.author)) appendThreadHistoryInfoText(infoMain, 'h-threads-info-email', item.author); const createdAtNode = appendThreadHistoryInfoText(infoMain, 'h-threads-info-createdat', item.createdAt); if (createdAtNode) { createdAtNode.dataset.xdexOriginalTime = item.createdAt; createdAtNode.title = item.createdAt; } const cookieHtml = item.cookieHtml || buildThreadHistoryLegacyCookieHtml(item.cookieId); const cookieMarkId = getThreadHistoryCookieMarkId(item); if (cookieHtml) { const cookieSpan = appendThreadHistoryText(infoMain, 'span', 'h-threads-info-uid', ''); if (cookieMarkId) cookieSpan.setAttribute('data-xdex-cookie-id', cookieMarkId); cookieSpan.innerHTML = cookieHtml; } else if (item.cookieId) { const cookieSpan = appendThreadHistoryInfoText(infoMain, 'h-threads-info-uid', `ID:${item.cookieId}`); if (cookieSpan && cookieMarkId) cookieSpan.setAttribute('data-xdex-cookie-id', cookieMarkId); } const historyReplyUrl = buildCanonicalReplyUrl(item.threadId, item.threadId); const historyReplyActionUrl = buildThreadHistoryItemUrl(item); const replyLink = document.createElement('a'); replyLink.className = 'h-threads-info-id xdex-history-thread-id'; replyLink.href = historyReplyUrl; replyLink.textContent = `No.${item.threadId || ''}`; infoMain.appendChild(replyLink); const replyAction = document.createElement('span'); replyAction.className = 'h-threads-info-reply-btn xdex-history-reply-label'; const replyActionLink = document.createElement('a'); replyActionLink.className = 'xdex-history-reply-action'; replyActionLink.href = historyReplyActionUrl; replyActionLink.target = '_blank'; replyActionLink.rel = 'noopener'; replyActionLink.textContent = '回应'; replyAction.appendChild(document.createTextNode('[')); replyAction.appendChild(replyActionLink); replyAction.appendChild(document.createTextNode(']')); infoMain.appendChild(replyAction); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'xdex-history-delete'; deleteButton.dataset.historyKey = result.key; deleteButton.title = '删除'; deleteButton.innerHTML = XDEX_SVG_X; main.appendChild(deleteButton); // 回收站标识: 该串原始浏览数据存于回收站时展示, 点击跳转定位(墓碑数据层就绪后自动生效) // 已恢复(revivedAt)的墓碑不算「在回收站中」——与 getThreadHistoryTombstoneList 的过滤条件保持一致 if (typeof getThreadHistoryStore === 'function') { const tomb = (getThreadHistoryStore().tombstones || {})[result.key]; if (tomb && !tomb.purged && !tomb.revivedAt) { const mark = document.createElement('button'); mark.type = 'button'; mark.className = 'xdex-history-tombstone-mark'; mark.title = `历史浏览数据在回收站中 · ${formatTombstoneRemainText(tomb.deletedAt, THREAD_HISTORY_TOMBSTONE_TTL_MS)}`; mark.setAttribute('aria-label', mark.title); mark.innerHTML = XDEX_SVG_TRASH; mark.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); openRecycleBinAndLocate(result.key); }); main.appendChild(mark); } } if (item.imageFile) { const imageLink = document.createElement('a'); imageLink.className = 'h-threads-img-a xdex-history-image'; imageLink.href = buildThreadHistoryImageUrl(item.imageFile, true); imageLink.dataset.historyQuoteId = item.threadId || ''; imageLink.target = '_blank'; imageLink.rel = 'noopener'; const img = document.createElement('img'); img.className = 'h-threads-img'; img.src = buildThreadHistoryImageUrl(item.imageFile, false); img.alt = item.imageFile; imageLink.appendChild(img); main.appendChild(imageLink); } if (item.sageHtml) { const sageDiv = document.createElement('div'); sageDiv.className = 'h-threads-tips uk-text-danger uk-text-bold'; sageDiv.innerHTML = item.sageHtml; main.appendChild(sageDiv); } const content = document.createElement('div'); content.className = 'h-threads-content'; if (item.contentHtml) content.innerHTML = item.contentHtml; else content.textContent = item.contentText || item.excerpt || ''; if (item.contentTruncated) { limitThreadHistoryContentText(content, THREAD_HISTORY_EXCERPT_LIMIT); appendThreadHistoryTruncationMarker(content); } main.appendChild(content); enhanceHistoryRenderedContent(content); const footer = document.createElement('div'); footer.className = 'xdex-history-footer'; appendThreadHistoryText(footer, 'span', 'xdex-history-time', formatThreadHistoryTime(item.lastVisitedAt)); appendThreadHistoryText(footer, 'span', 'xdex-history-visit-count', `共访问 ${item.visitCount || 1} 次`); appendThreadHistoryText(footer, 'span', 'xdex-history-page', `串内最远:P${item.maxVisitedPage || item.page || 1}`); appendThreadHistoryText(footer, 'span', 'xdex-history-current-page', `最近查看:P${item.page || 1}`); if (item.mode === 'po') appendThreadHistoryText(footer, 'span', 'xdex-history-po-label', 'Po'); main.appendChild(footer); enhanceHistoryRenderedContent(footer); markAllCookies(getHistoryMarkedGroups(), wrapper); return wrapper; } const HISTORY_RENDER_INITIAL_COUNT = 24; const HISTORY_RENDER_BATCH_SIZE = 20; const HISTORY_RENDER_BATCH_THRESHOLD = 400; const historyRenderQueues = new Map(); function findHistoryScrollContainer(element) { let el = element; while (el && el !== document.body && el !== document.documentElement) { const style = window.getComputedStyle(el); if (style.overflowY === 'auto' || style.overflowY === 'scroll' || style.overflow === 'auto' || style.overflow === 'scroll') { return el; } el = el.parentElement; } return element; } function cancelHistoryRenderQueue(queueId) { const prev = historyRenderQueues.get(queueId); if (!prev) return; prev.cancelled = true; if (prev.scrollHandler && prev.scrollContainer) { prev.scrollContainer.removeEventListener('scroll', prev.scrollHandler, { passive: true }); } historyRenderQueues.delete(queueId); } function batchRenderHistoryItems(root, results, buildFn, queueId) { // 新批次顶替旧批次: 同队列已有在途渲染一律取消 cancelHistoryRenderQueue(queueId); if (!root) return; const total = results.length; if (total <= 0) return; let cursor = 0; const state = { cancelled: false }; const scrollContainer = findHistoryScrollContainer(root); historyRenderQueues.set(queueId, state); function appendBatch(count) { if (state.cancelled) return; const slice = results.slice(cursor, cursor + count); const fragment = document.createDocumentFragment(); slice.forEach(r => fragment.appendChild(buildFn(r))); root.appendChild(fragment); cursor += slice.length; } function maybeLoadMore() { if (state.cancelled || cursor >= total) return; const nearBottom = scrollContainer.scrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - HISTORY_RENDER_BATCH_THRESHOLD; if (nearBottom) { appendBatch(HISTORY_RENDER_BATCH_SIZE); requestAnimationFrame(() => { if (!state.cancelled) maybeLoadMore(); }); } } const scrollHandler = () => { if (state.cancelled) return; if (cursor >= total) return; requestAnimationFrame(() => { if (state.cancelled) return; maybeLoadMore(); }); }; state.scrollHandler = scrollHandler; state.scrollContainer = scrollContainer; scrollContainer.addEventListener('scroll', scrollHandler, { passive: true }); appendBatch(HISTORY_RENDER_INITIAL_COUNT); if (cursor < total) { requestAnimationFrame(() => { if (state.cancelled) return; maybeLoadMore(); }); } } // ===== 共享 SVG 图标(统一视觉,替代字符"×"等占位) ===== const XDEX_SVG_X = '<svg viewBox="0 0 24 24" style="display:block;width:11px;height:11px;margin:auto;" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg>'; const XDEX_SVG_TRASH = '<svg viewBox="0 0 24 24" style="display:block;width:13px;height:13px;" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>'; // 分组行拖拽手柄图标(六点:2 列 × 3 行,点距紧凑,参考常见拖拽手柄样式) const XDEX_SVG_GRIP = '<svg viewBox="0 0 10 16" style="display:block;width:10px;height:16px;margin:auto;" fill="currentColor" aria-hidden="true"><circle cx="2.5" cy="3" r="1.3"></circle><circle cx="7.5" cy="3" r="1.3"></circle><circle cx="2.5" cy="8" r="1.3"></circle><circle cx="7.5" cy="8" r="1.3"></circle><circle cx="2.5" cy="13" r="1.3"></circle><circle cx="7.5" cy="13" r="1.3"></circle></svg>'; // 回复模式行按钮图标(16px,与刷新饼干等 xdex-icon-btn 图标同尺寸) const XDEX_SVG_X_ICON = '<svg viewBox="0 0 24 24" style="display:block;width:16px;height:16px;" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg>'; const XDEX_SVG_STOPWATCH = '<svg viewBox="0 0 24 24" style="display:block;width:16px;height:16px;" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="13.6" r="7.4"></circle><path d="M12 13.6V9.8"></path><path d="M9.2 2.4h5.6"></path><path d="M12 2.4v3.8"></path><path d="M17.9 8.2l1.7-1.7"></path></svg>'; const XDEX_SVG_INFINITY = '<svg viewBox="0 0 24 24" style="display:block;width:16px;height:16px;" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 12c-2-2.67-4-4-6-4a4 4 0 1 0 0 8c2 0 4-1.33 6-4Zm0 0c2 2.67 4 4 6 4a4 4 0 0 0 0-8c-2 0-4 1.33-6 4Z"></path></svg>'; const XDEX_SVG_SWITCH = '<svg viewBox="0 0 24 24" style="display:block;width:16px;height:16px;" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-5 5"></path><path d="M21 8H5"></path><path d="m8 21-5-5 5-5"></path><path d="M3 16h16"></path></svg>'; const XDEX_SVG_RESTORE_ICON = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 18A8.5 8.5 0 1 1 18.5 6.5"></path><path d="M18.5 6.5l-1.3 3.6"></path><path d="M19.2 10.6L18.5 6.5l-3.2 2.7"></path></svg>'; const XDEX_SVG_PURGE_ICON = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>'; // 剩余时间略写: 24 小时内显示小时数, 超过显示天数 function formatTombstoneRemainText(deletedAt, ttlMs) { const remain = Math.max(0, (Number(deletedAt) || 0) + (ttlMs || 0) - Date.now()); const hours = Math.ceil(remain / (60 * 60 * 1000)); return hours <= 24 ? `${hours} 小时后删除` : `${Math.ceil(remain / (24 * 60 * 60 * 1000))} 天后删除`; } const THREAD_HISTORY_TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000; const THREAD_HISTORY_TOMBSTONE_HARD_TTL_MS = 180 * 24 * 60 * 60 * 1000; const POST_HISTORY_TOMBSTONE_HARD_TTL_MS = 180 * 24 * 60 * 60 * 1000; const POST_HISTORY_TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // ===== 回收站 UI(墓碑数据层未接入前 tombstones 恒为空,视图显示空态) ===== let threadHistoryRecycleMode = false; let threadHistoryRecycleSort = 'expiring'; // 'expiring'=即将删除优先 | 'recent'=最近删除优先 function getThreadHistoryTombstoneList() { const store = getThreadHistoryStore(); const tombs = store.tombstones || {}; let arr = Object.keys(tombs) // 排除已彻底删除(purged)与已恢复(revivedAt)的墓碑: // 恢复后墓碑保留(revivedAt 供 WebDAV 传播、record 已清空)但不应再出现在回收站视图 .filter((key) => !tombs[key].purged && !tombs[key].revivedAt) .map((key) => Object.assign({ key }, tombs[key])); const nowTs = Date.now(); if (threadHistoryRecycleSort === 'due24h') { // 筛选: 剩余不足 24 小时的条目, 仍按即将删除优先排列 arr = arr.filter(t => ((Number(t.deletedAt) || 0) + THREAD_HISTORY_TOMBSTONE_TTL_MS - nowTs) <= 24 * 60 * 60 * 1000); arr.sort((a, b) => (Number(a.deletedAt) || 0) - (Number(b.deletedAt) || 0)); } else { arr.sort((a, b) => ((Number(a.deletedAt) || 0) - (Number(b.deletedAt) || 0)) * (threadHistoryRecycleSort === 'recent' ? -1 : 1)); } return arr; } function openRecycleBinAndLocate(key) { setThreadHistoryRecycleMode(true); requestAnimationFrame(() => { const el = document.querySelector(`#sp_history_results [data-recycle-key="${CSS.escape(key)}"]`); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('xdex-recycle-highlight'); setTimeout(() => el.classList.remove('xdex-recycle-highlight'), 2000); }); } function updateThreadHistoryRecycleBadge() { const badge = document.getElementById('sp_history_recycle_badge'); if (!badge) return; const list = getThreadHistoryTombstoneList(); // 角标仅在有条目"剩余不足 24 小时"(即将被彻底删除)时显示 const dueCutoff = Date.now() + 24 * 60 * 60 * 1000; const dueSoonCount = list.filter(t => { const expireAt = (Number(t.deletedAt) || 0) + THREAD_HISTORY_TOMBSTONE_TTL_MS; return expireAt > Date.now() && expireAt <= dueCutoff; }).length; badge.textContent = String(dueSoonCount); badge.hidden = dueSoonCount === 0; } function renderThreadHistoryRecycleView() { const root = document.getElementById('sp_history_results'); if (!root) return; const list = getThreadHistoryTombstoneList(); const barCount = document.getElementById('sp_history_recycle_barcount'); if (barCount) barCount.textContent = `${list.length} 条`; const emptyBtn = document.getElementById('sp_history_recycle_empty'); if (emptyBtn) emptyBtn.disabled = list.length === 0; root.textContent = ''; const now = Date.now(); const liveStore = getThreadHistoryStore(); for (const t of list) { const rec = t.record || {}; // 删除后的新段(items 中被压制的独立计数)动态合成展示 const shadow = liveStore.items[t.key] || null; const newVisits = shadow ? (Number(shadow.visitCount) || 0) : 0; // 合成展示视图: 快照为基础; 最近访问/最远页码与新段同步取最新, 与回收站信息保持一致 const itemView = Object.assign({}, rec); if (shadow) { itemView.lastVisitedAt = Math.max(Number(rec.lastVisitedAt) || 0, Number(shadow.lastVisitedAt) || 0); itemView.maxVisitedPage = Math.max(Number(rec.maxVisitedPage) || 0, Number(shadow.maxVisitedPage) || 0); itemView.page = Math.max(Number(rec.page) || 0, Number(shadow.page) || 0); } // 复用正常条目构建器, 展示形式与主列表完全一致 const card = buildThreadHistoryItemElement({ key: t.key, item: itemView, index: buildThreadHistoryIndexEntry(itemView) }); const legacyDelete = card.querySelector('.xdex-history-delete'); if (legacyDelete) legacyDelete.remove(); const legacyMark = card.querySelector('.xdex-history-tombstone-mark'); if (legacyMark) legacyMark.remove(); const item = document.createElement('div'); item.className = 'xdex-recycle-item'; item.dataset.recycleKey = t.key; item.appendChild(card); const metaEl = document.createElement('div'); metaEl.className = 'xdex-recycle-item-meta'; const metaText = document.createElement('span'); metaText.textContent = `删除于 ${new Date(t.deletedAt).toLocaleString('zh-CN', { hour12: false })} · ${formatTombstoneRemainText(t.deletedAt, THREAD_HISTORY_TOMBSTONE_TTL_MS)}${newVisits > 0 ? ` · 新增访问 ${newVisits} 次` : ''}`; metaEl.appendChild(metaText); const restoreBtn = document.createElement('button'); restoreBtn.type = 'button'; restoreBtn.className = 'xdex-recycle-item-btn xdex-recycle-item-restore'; restoreBtn.dataset.recycleKey = t.key; restoreBtn.title = '恢复该条目到浏览历史'; restoreBtn.setAttribute('aria-label', '恢复'); restoreBtn.innerHTML = XDEX_SVG_RESTORE_ICON; const purgeBtn = document.createElement('button'); purgeBtn.type = 'button'; purgeBtn.className = 'xdex-recycle-item-btn xdex-recycle-item-purge'; purgeBtn.dataset.recycleKey = t.key; purgeBtn.title = '彻底删除(无法恢复)'; purgeBtn.setAttribute('aria-label', '彻底删除'); purgeBtn.innerHTML = XDEX_SVG_PURGE_ICON; metaEl.appendChild(restoreBtn); metaEl.appendChild(purgeBtn); item.appendChild(metaEl); root.appendChild(item); } if (!list.length) { const empty = document.createElement('div'); empty.className = 'xdex-recycle-empty'; empty.innerHTML = '回收站为空<br><span style="font-size:12px;opacity:.8;">已删除的浏览记录会在这里保留 30 天,期间可随时恢复;到期未恢复将自动彻底清除</span>'; root.appendChild(empty); } } function setThreadHistoryRecycleMode(on) { const next = !!on; if (threadHistoryRecycleMode === next) return; threadHistoryRecycleMode = next; // 进入回收站前终止主列表在途批次: 否则旧批次回调会在回收站视图上继续追加普通条目 if (next) cancelHistoryRenderQueue('threadHistory'); const content = document.getElementById('sp_history_content'); if (content) content.classList.toggle('xdex-recycle-mode', threadHistoryRecycleMode); const bar = document.getElementById('sp_history_recycle_bar'); if (bar) bar.hidden = !threadHistoryRecycleMode; if (threadHistoryRecycleMode) { renderThreadHistoryRecycleView(); } else { renderThreadHistoryModule(); } } function renderThreadHistoryModule(query) { if (threadHistoryRecycleMode) { renderThreadHistoryRecycleView(); return; } updateThreadHistoryRecycleBadge(); const root = document.getElementById('sp_history_results'); if (!root) { logThreadHistory('render skipped: missing #sp_history_results'); return; } invalidateHistoryRenderCaches(); const input = document.getElementById('sp_history_search'); const sortSelect = document.getElementById('sp_history_sort'); const effectiveQuery = query == null && input ? input.value : query; const sortMode = sortSelect ? sortSelect.value : 'last-desc'; const results = searchThreadHistory(effectiveQuery || '', null, sortMode); updateThreadHistoryDebugState({ lastRender: { query: effectiveQuery || '', sortMode, count: results.length, at: new Date().toISOString() } }); logThreadHistory('render module', { query: effectiveQuery || '', sortMode, count: results.length }); const count = document.getElementById('sp_history_count'); if (count) { count.textContent = `${results.length} 条 `; count.appendChild(buildHistorySearchHelpMark(THREAD_HISTORY_SEARCH_HELP_TEXT)); } root.textContent = ''; if (!results.length) { const empty = document.createElement('div'); empty.className = 'xdex-history-empty'; empty.textContent = effectiveQuery ? '没有匹配的浏览历史' : '暂无浏览历史'; root.appendChild(empty); return; } batchRenderHistoryItems(root, results, buildThreadHistoryItemElement, 'threadHistory'); const reportThreadHistoryRenderDom = () => { const cover = document.getElementById('sp_cover'); const panel = document.getElementById('sp_panel'); const views = document.getElementById('sp_panel_views'); const module = document.getElementById('sp_module_history'); const panelContent = document.querySelector('#sp_module_history .sp_panel_content'); const historyContent = document.getElementById('sp_history_content'); const firstItem = root.querySelector('.xdex-history-item'); updateThreadHistoryDebugState({ lastRenderDom: { activeModule: module?.classList.contains('active') || false, contentDisplay: getComputedStyle(historyContent || root).display, resultsDisplay: getComputedStyle(root).display, coverDisplay: getComputedStyle(cover || document.body).display, panelDisplay: getComputedStyle(panel || document.body).display, viewsDisplay: getComputedStyle(views || document.body).display, moduleDisplay: getComputedStyle(module || document.body).display, panelContentDisplay: getComputedStyle(panelContent || document.body).display, childCount: root.children.length, itemCount: root.querySelectorAll('.xdex-history-item').length, coverHeight: cover?.offsetHeight || 0, panelHeight: panel?.offsetHeight || 0, viewsHeight: views?.offsetHeight || 0, moduleHeight: module?.offsetHeight || 0, panelContentHeight: panelContent?.offsetHeight || 0, historyContentHeight: historyContent?.offsetHeight || 0, offsetHeight: root.offsetHeight, scrollHeight: root.scrollHeight, firstItemHeight: firstItem?.offsetHeight || 0, firstItemText: firstItem?.textContent?.slice(0, 80) || '', at: new Date().toISOString() } }); logThreadHistory('render dom', threadHistoryDebugState.lastRenderDom); logThreadHistoryFlat('render dom flat', threadHistoryDebugState.lastRenderDom); }; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reportThreadHistoryRenderDom); else setTimeout(reportThreadHistoryRenderDom, 0); } function renderThreadHistoryModuleSoon(query) { if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => renderThreadHistoryModule(query)); return; } setTimeout(() => renderThreadHistoryModule(query), 0); } function openHistoryImageQuotePreview(tid) { const quoteId = String(tid || '').trim(); if (!/^\d+$/.test(quoteId) || quoteId === '9999999') return false; try { // 由调用方写入的“当前串”上下文(保持函数签名契约不变) const ctxThreadId = window.__xdexPendingQuoteCtxTid || ((typeof PageType !== 'undefined' && PageType.getThreadId) ? PageType.getThreadId(true) : ''); window.__xdexPendingQuoteCtxTid = ''; if (typeof window.__xdexOpenQuoteByTid !== 'function' && typeof enableQuotePreview === 'function') { enableQuotePreview(); } if (typeof window.__xdexOpenQuoteByTid !== 'function') return false; const ret = window.__xdexOpenQuoteByTid(quoteId, { fromPOImage: true }); if (ret && typeof ret.then === 'function') ret.catch(() => {}); return true; } catch (e) { return false; } } let historyEnhanceCfgCache = null; let historyMarkedGroupsCache = null; function invalidateHistoryRenderCaches() { historyEnhanceCfgCache = null; historyMarkedGroupsCache = null; } function getHistoryEnhanceCfg() { if (historyEnhanceCfgCache) return historyEnhanceCfgCache; try { historyEnhanceCfgCache = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); } catch (e) { historyEnhanceCfgCache = Object.assign({}, (SettingPanel && SettingPanel.defaults) || {}); } return historyEnhanceCfgCache; } function getHistoryMarkedGroups() { if (historyMarkedGroupsCache) return historyMarkedGroupsCache; try { const cfg = typeof getFilterConfig === 'function' ? getFilterConfig() : getHistoryEnhanceCfg(); historyMarkedGroupsCache = (cfg && cfg.markedGroups) || []; } catch (e) { historyMarkedGroupsCache = []; } return historyMarkedGroupsCache; } function enhanceHistoryRenderedContent(root) { if (!root) return; try { renderHiddenTextContent(root); } catch (e) {} try { if (typeof extendQuote === 'function') extendQuote(root); } catch (e) {} try { if (typeof initExtendedContent === 'function') initExtendedContent(root); } catch (e) {} try { const cfg = getHistoryEnhanceCfg(); if (cfg && cfg.enableImageHideMode) applyImageHideMode(cfg.applyImageHideMode || 'default', root); if (cfg && cfg.enableAutoUrlLinkify && typeof runAutoUrlLinkify === 'function') runAutoUrlLinkify(root); } catch (e) {} } // 清空历史二次确认:首层自定义弹窗(可高亮「清空」「无法恢复」),确认后再走 window.confirm function showClearHistoryWarningDialog(onConfirm, onCancel) { const existing = document.getElementById('xdex-clear-history-warning-modal'); if (existing) existing.remove(); const $m = $( '<div id="xdex-clear-history-warning-modal" tabindex="-1" style="position:fixed;inset:0;z-index:10003;display:flex;align-items:center;justify-content:center;outline:none;">' + '<div class="xdex-clear-history-warning-backdrop" style="position:absolute;inset:0;background:rgba(0,0,0,.45);"></div>' + '<div class="xdex-clear-history-warning-dialog" role="dialog" aria-modal="true" aria-labelledby="xdex-clear-history-warning-title" style="position:relative;width:min(380px,92vw);background:var(--xdex-sp-panel-bg, #FFFFEE);border:1px solid var(--xdex-sp-border, #ccc);border-radius:8px;box-shadow:0 2px 12px var(--xdex-sp-shadow, rgba(0,0,0,.25));padding:16px 18px 14px;color:var(--foreground, #333);">' + '<h3 id="xdex-clear-history-warning-title" style="margin:0 0 10px;font-size:16px;font-weight:700;color:#c62828;">操作警告</h3>' + '<p style="margin:0 0 8px;font-size:14px;line-height:1.65;">请注意此按钮为' + '<span style="color:#c62828;font-weight:800;font-size:16px;padding:0 2px;">「清空」</span>' + '按钮而非「搜索」</p>' + '<p style="margin:0 0 12px;font-size:14px;line-height:1.65;">操作后将' + '<span style="color:#c62828;font-weight:800;font-size:16px;padding:0 2px;">无法恢复</span></p>' + '<p style="margin:0 0 14px;font-size:13px;color:var(--muted-foreground, #666);">是否确认继续?</p>' + '<div style="display:flex;gap:8px;justify-content:flex-end;">' + '<button type="button" id="xdex-clear-history-warning-cancel" style="padding:6px 14px;">取消</button>' + '<button type="button" id="xdex-clear-history-warning-ok" style="padding:6px 14px;background:#c62828;color:#fff;border:1px solid #b71c1c;border-radius:4px;font-weight:700;">确认清空</button>' + '</div>' + '</div>' + '</div>' ); function close() { $m.remove(); $(document).off('keydown.xdex-clear-history-warning'); } function cancel() { close(); if (typeof onCancel === 'function') onCancel(); } function confirm() { close(); if (typeof onConfirm === 'function') onConfirm(); } $m.find('.xdex-clear-history-warning-backdrop').on('click', cancel); $m.find('#xdex-clear-history-warning-cancel').on('click', cancel); $m.find('#xdex-clear-history-warning-ok').on('click', confirm); $(document).on('keydown.xdex-clear-history-warning', function (e) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } // 危险操作:不响应 Enter 确认,必须点红色「确认清空」 }); $('body').append($m); try { $m.find('#xdex-clear-history-warning-cancel')[0].focus(); } catch (e) {} } // 检索输入统一绑定:拼音等输入法合成期间不检索,合成结束或停止输入后延迟一次, // 避免半截关键词反复触发全表扫描;回车立即检索并取消在途定时器 function bindHistorySearchInput(selector, render, namespace) { const el = $(selector)[0]; if (!el) return; if (el.__xdexSearchTimer) { clearTimeout(el.__xdexSearchTimer); el.__xdexSearchTimer = 0; } const schedule = (value) => { if (el.__xdexSearchTimer) clearTimeout(el.__xdexSearchTimer); el.__xdexSearchTimer = setTimeout(() => { el.__xdexSearchTimer = 0; if (document.contains(el)) render(value); }, HISTORY_SEARCH_INPUT_DEBOUNCE_MS); }; $(el) .off('.' + namespace) .on('compositionstart.' + namespace, () => { el.__xdexSearchComposing = true; }) .on('compositionend.' + namespace, (e) => { el.__xdexSearchComposing = false; schedule(e.target.value || ''); }) .on('input.' + namespace, function () { if (el.__xdexSearchComposing) return; schedule(this.value || ''); }) .on('keydown.' + namespace, function (e) { if (e.key !== 'Enter') return; if (el.__xdexSearchTimer) { clearTimeout(el.__xdexSearchTimer); el.__xdexSearchTimer = 0; } render(this.value || ''); }); } function bindThreadHistoryModuleEvents() { bindHistorySearchInput('#sp_history_search', renderThreadHistoryModule, 'xdex-history'); $('#sp_history_sort').off('change.xdex-history').on('change.xdex-history', function () { renderThreadHistoryModule(); }); $('#sp_history_results').off('click.xdex-history-reply', '.xdex-history-reply-action').on('click.xdex-history-reply', '.xdex-history-reply-action', function (e) { if (e.button !== 0) return; const url = this.href || ''; if (!url) return; e.preventDefault(); if (e.ctrlKey || e.metaKey) { window.location.href = url; return; } window.open(url, '_blank', 'noopener'); }); $('#sp_history_results').off('click.xdex-history-image-quote', '.xdex-history-image').on('click.xdex-history-image-quote', '.xdex-history-image', function (e) { if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; window.__xdexPendingQuoteCtxTid = (window.__xdexQuoteRefMark && window.__xdexQuoteRefMark.getContextThreadId(this)) || ''; const opened = openHistoryImageQuotePreview(this.dataset.historyQuoteId || ''); if (!opened) return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }); $('#sp_history_results').off('click.xdex-history-delete', '.xdex-history-delete').on('click.xdex-history-delete', '.xdex-history-delete', function (e) { e.preventDefault(); const key = this.dataset.historyKey || ''; if (!key) return; deleteThreadHistoryItem(key); renderThreadHistoryModule(); toast('已移入回收站,30 天后自动清除,可在回收站恢复'); }); $('#sp_history_clear').off('click.xdex-history').on('click.xdex-history', function (e) { e.preventDefault(); showClearHistoryWarningDialog(function () { if (!window.confirm('确定要清空全部浏览历史吗?')) return; clearThreadHistory(); renderThreadHistoryModule(); toast('已清空浏览历史(已移入回收站,30 天内可恢复)'); }); }); $('#sp_history_recycle').off('click.xdex-history-recycle').on('click.xdex-history-recycle', function (e) { e.preventDefault(); setThreadHistoryRecycleMode(true); }); $('#sp_history_recycle_back').off('click.xdex-history-recycle').on('click.xdex-history-recycle', function (e) { e.preventDefault(); setThreadHistoryRecycleMode(false); }); $('#sp_history_recycle_sort').off('change.xdex-history-recycle').on('change.xdex-history-recycle', function () { threadHistoryRecycleSort = this.value; renderThreadHistoryRecycleView(); }); $('#sp_history_results').off('click.xdex-history-recycle-action', '.xdex-recycle-item-restore').on('click.xdex-history-recycle-action', '.xdex-recycle-item-restore', function (e) { e.preventDefault(); e.stopPropagation(); const key = this.dataset.recycleKey || ''; if (!key) return; restoreThreadHistoryFromTombstone(key); renderThreadHistoryRecycleView(); toast('已恢复该浏览记录'); }); $('#sp_history_results').off('click.xdex-history-recycle-action', '.xdex-recycle-item-purge').on('click.xdex-history-recycle-action', '.xdex-recycle-item-purge', function (e) { e.preventDefault(); e.stopPropagation(); const key = this.dataset.recycleKey || ''; if (!key) return; if (!window.confirm('确定要彻底删除这条记录吗?此操作无法通过恢复找回。')) return; purgeThreadHistoryTombstone(key); renderThreadHistoryRecycleView(); toast('已彻底删除'); }); $('#sp_history_recycle_empty').off('click.xdex-history-recycle-empty').on('click.xdex-history-recycle-empty', function (e) { e.preventDefault(); if (this.disabled) return; if (!window.confirm('确定要清空回收站吗?全部待删除记录将被彻底清除,无法恢复。')) return; purgeAllThreadHistoryTombstones(); renderThreadHistoryRecycleView(); toast('回收站已清空'); }); } function buildPostHistoryItemElement(result) { const item = result.item || {}; const wrapper = document.createElement('div'); wrapper.className = 'xdex-history-item xdex-post-history-item'; wrapper.dataset.postHistoryKey = result.key; const main = document.createElement('div'); main.className = 'h-threads-item-main'; wrapper.appendChild(main); const info = document.createElement('div'); info.className = 'h-threads-info xdex-history-info xdex-post-history-info'; main.appendChild(info); const infoMain = document.createElement('span'); infoMain.className = 'xdex-history-info-main'; info.appendChild(infoMain); if (shouldRenderThreadHistoryTitle(item.title)) appendThreadHistoryInfoText(infoMain, 'h-threads-info-title', item.title); if (shouldRenderThreadHistoryAuthor(item.name)) appendThreadHistoryInfoText(infoMain, 'h-threads-info-email', item.name); if (item.email) appendThreadHistoryInfoText(infoMain, 'h-threads-info-email', item.email); const submittedAtText = formatRelativeTimeMachineTime(item.submittedAt); const createdAtNode = appendThreadHistoryInfoText(infoMain, 'h-threads-info-createdat', submittedAtText); if (createdAtNode) { createdAtNode.dataset.xdexOriginalTime = submittedAtText; createdAtNode.title = submittedAtText; } if (item.userHash) appendThreadHistoryInfoText(infoMain, 'h-threads-info-uid', `ID:${item.userHash}`); const displayPostId = item.postId || item.id || (item.type === 'reply' ? '' : item.threadId); const postUrl = buildPostHistoryUrl(item.type, displayPostId, item.resto || item.threadId); const postReplyActionUrl = buildPostHistoryReplyActionUrl(item.type, displayPostId, item.resto || item.threadId, item.page); if (postUrl) { const postLink = document.createElement('a'); postLink.className = 'h-threads-info-id xdex-post-history-thread-id'; postLink.href = postUrl; postLink.textContent = `No.${displayPostId}`; infoMain.appendChild(postLink); const replyAction = document.createElement('span'); replyAction.className = 'h-threads-info-reply-btn xdex-post-history-reply-label'; const replyActionLink = document.createElement('a'); replyActionLink.className = 'xdex-post-history-reply-action'; replyActionLink.href = postReplyActionUrl; replyActionLink.target = '_blank'; replyActionLink.rel = 'noopener'; replyActionLink.textContent = '回应'; replyAction.appendChild(document.createTextNode('[')); replyAction.appendChild(replyActionLink); replyAction.appendChild(document.createTextNode(']')); infoMain.appendChild(replyAction); } else { appendThreadHistoryInfoText(infoMain, 'h-threads-info-id xdex-post-history-thread-id', '未确认'); } const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'xdex-post-history-delete'; deleteButton.dataset.postHistoryKey = result.key; deleteButton.title = '删除'; deleteButton.innerHTML = XDEX_SVG_X; main.appendChild(deleteButton); // 回收站标识: 该发言原始数据存于回收站时展示, 点击跳转定位(墓碑数据层就绪后自动生效) // 已恢复(revivedAt)的墓碑不算「在回收站中」——与 getPostHistoryTombstoneList 的过滤条件保持一致 if (typeof getPostHistoryStore === 'function') { const tomb = (getPostHistoryStore().tombstones || {})[result.key]; if (tomb && !tomb.purged && !tomb.revivedAt) { const mark = document.createElement('button'); mark.type = 'button'; mark.className = 'xdex-post-history-tombstone-mark'; mark.title = `历史发言数据在回收站中 · ${formatTombstoneRemainText(tomb.deletedAt, POST_HISTORY_TOMBSTONE_TTL_MS)}`; mark.setAttribute('aria-label', mark.title); mark.innerHTML = XDEX_SVG_TRASH; mark.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); openPostRecycleBinAndLocate(result.key); }); main.appendChild(mark); } } if (item.imageFile) { const imageLink = document.createElement('a'); imageLink.className = 'h-threads-img-a xdex-post-history-image'; imageLink.href = buildThreadHistoryImageUrl(item.imageFile, true); imageLink.dataset.postHistoryQuoteId = displayPostId || item.threadId || ''; imageLink.target = '_blank'; imageLink.rel = 'noopener'; const image = document.createElement('img'); image.className = 'h-threads-img'; image.src = buildThreadHistoryImageUrl(item.imageFile, false); image.alt = item.imageFile; imageLink.appendChild(image); main.appendChild(imageLink); } const content = document.createElement('div'); content.className = 'h-threads-content'; if (item.contentHtml) content.innerHTML = item.contentHtml; else content.textContent = item.contentText || item.contentRaw || ''; main.appendChild(content); enhanceHistoryRenderedContent(content); const footer = document.createElement('div'); footer.className = 'xdex-history-footer xdex-post-history-footer'; if (item.status !== 'confirmed') appendThreadHistoryText(footer, 'span', 'xdex-post-history-status', item.status === 'pending' ? '确认中' : item.status === 'failed' ? '失败' : '未确认'); const forumName = item.forumName || getPostHistoryForumNameByFid(item.fid); if (forumName) appendThreadHistoryText(footer, 'span', 'xdex-post-history-forum', `${forumName}`); appendThreadHistoryText(footer, 'span', 'xdex-post-history-type', item.type === 'reply' ? '回复' : '主题'); if (item.threadId) appendThreadHistoryText(footer, 'span', 'xdex-post-history-thread', `串号:${item.threadId}`); // 优先从浏览历史获取最近浏览页(仅 thread 类型),否则回退到记录中的 page // const displayPage = item.page; let displayPage = item.page; if (item.type === 'thread' && item.threadId) { const historyUrl = getLatestThreadHistoryUrl(item.threadId); if (historyUrl) { const parsed = parseThreadHistoryUrl(historyUrl); if (parsed && parsed.page) displayPage = parsed.page; } } if (displayPage) appendThreadHistoryText(footer, 'span', 'xdex-post-history-page', `所在页:P${displayPage}`); main.appendChild(footer); enhanceHistoryRenderedContent(footer); markAllCookies(getHistoryMarkedGroups(), wrapper); return wrapper; } // ===== 发言历史回收站 UI(与浏览历史同构) ===== let postHistoryRecycleMode = false; let postHistoryRecycleSort = 'expiring'; function getPostHistoryTombstoneList() { const store = getPostHistoryStore(); const tombs = store.tombstones || {}; let arr = Object.keys(tombs) // 排除已彻底删除(purged)与已恢复(revivedAt)的墓碑: // 恢复后墓碑保留(revivedAt 供 WebDAV 传播、record 已清空)但不应再出现在回收站视图 .filter((key) => !tombs[key].purged && !tombs[key].revivedAt) .map((key) => Object.assign({ key }, tombs[key])); const nowTs = Date.now(); if (postHistoryRecycleSort === 'due24h') { arr = arr.filter(t => ((Number(t.deletedAt) || 0) + POST_HISTORY_TOMBSTONE_TTL_MS - nowTs) <= 24 * 60 * 60 * 1000); arr.sort((a, b) => (Number(a.deletedAt) || 0) - (Number(b.deletedAt) || 0)); } else { arr.sort((a, b) => ((Number(a.deletedAt) || 0) - (Number(b.deletedAt) || 0)) * (postHistoryRecycleSort === 'recent' ? -1 : 1)); } return arr; } function openPostRecycleBinAndLocate(key) { setPostHistoryRecycleMode(true); requestAnimationFrame(() => { const el = document.querySelector(`#sp_posts_results [data-recycle-key="${CSS.escape(key)}"]`); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('xdex-recycle-highlight'); setTimeout(() => el.classList.remove('xdex-recycle-highlight'), 2000); }); } function updatePostHistoryRecycleBadge() { const badge = document.getElementById('sp_posts_recycle_badge'); if (!badge) return; const list = getPostHistoryTombstoneList(); // 角标仅在有条目"剩余不足 24 小时"(即将被彻底删除)时显示 const dueCutoff = Date.now() + 24 * 60 * 60 * 1000; const dueSoonCount = list.filter(t => { const expireAt = (Number(t.deletedAt) || 0) + POST_HISTORY_TOMBSTONE_TTL_MS; return expireAt > Date.now() && expireAt <= dueCutoff; }).length; badge.textContent = String(dueSoonCount); badge.hidden = dueSoonCount === 0; } function renderPostHistoryRecycleView() { const root = document.getElementById('sp_posts_results'); if (!root) return; const list = getPostHistoryTombstoneList(); const barCount = document.getElementById('sp_posts_recycle_barcount'); if (barCount) barCount.textContent = `${list.length} 条`; const emptyBtn = document.getElementById('sp_posts_recycle_empty'); if (emptyBtn) emptyBtn.disabled = list.length === 0; root.textContent = ''; const now = Date.now(); const liveStore = getPostHistoryStore(); for (const t of list) { const rec = t.record || {}; // 删除后的新记录(items 中被压制的独立条目)动态合成展示 const shadow = liveStore.items[t.key] || null; // 合成展示视图: 快照为基础; 页码/时间与新记录同步取最新 const itemView = Object.assign({}, rec); if (shadow) { itemView.submittedAt = Math.min(Number(rec.submittedAt) || Infinity, Number(shadow.submittedAt) || Infinity); itemView.page = Math.max(Number(rec.page) || 0, Number(shadow.page) || 0); itemView.contentText = shadow.contentText || rec.contentText; } // 复用正常条目构建器, 展示形式与发言历史主列表完全一致 const card = buildPostHistoryItemElement({ key: t.key, item: itemView }); const legacyDelete = card.querySelector('.xdex-post-history-delete'); if (legacyDelete) legacyDelete.remove(); const legacyMark = card.querySelector('.xdex-post-history-tombstone-mark'); if (legacyMark) legacyMark.remove(); const item = document.createElement('div'); item.className = 'xdex-recycle-item'; item.dataset.recycleKey = t.key; item.appendChild(card); const metaEl = document.createElement('div'); metaEl.className = 'xdex-recycle-item-meta'; const metaText = document.createElement('span'); metaText.textContent = `删除于 ${new Date(t.deletedAt).toLocaleString('zh-CN', { hour12: false })} · ${formatTombstoneRemainText(t.deletedAt, POST_HISTORY_TOMBSTONE_TTL_MS)}${shadow ? ' · 删除后有新记录' : ''}`; metaEl.appendChild(metaText); const restoreBtn = document.createElement('button'); restoreBtn.type = 'button'; restoreBtn.className = 'xdex-recycle-item-btn xdex-recycle-item-restore'; restoreBtn.dataset.recycleKey = t.key; restoreBtn.title = '恢复该条目到发言历史'; restoreBtn.setAttribute('aria-label', '恢复'); restoreBtn.innerHTML = XDEX_SVG_RESTORE_ICON; const purgeBtn = document.createElement('button'); purgeBtn.type = 'button'; purgeBtn.className = 'xdex-recycle-item-btn xdex-recycle-item-purge'; purgeBtn.dataset.recycleKey = t.key; purgeBtn.title = '彻底删除(无法恢复)'; purgeBtn.setAttribute('aria-label', '彻底删除'); purgeBtn.innerHTML = XDEX_SVG_PURGE_ICON; metaEl.appendChild(restoreBtn); metaEl.appendChild(purgeBtn); item.appendChild(metaEl); root.appendChild(item); } if (!list.length) { const empty = document.createElement('div'); empty.className = 'xdex-recycle-empty'; empty.innerHTML = '回收站为空<br><span style="font-size:12px;opacity:.8;">已删除的发言记录会在这里保留 30 天,期间可随时恢复;到期未恢复将自动彻底清除</span>'; root.appendChild(empty); } } function setPostHistoryRecycleMode(on) { const next = !!on; if (postHistoryRecycleMode === next) return; postHistoryRecycleMode = next; // 进入回收站前终止主列表在途批次 if (next) cancelHistoryRenderQueue('postHistory'); const content = document.getElementById('sp_posts_content'); if (content) content.classList.toggle('xdex-recycle-mode', postHistoryRecycleMode); const bar = document.getElementById('sp_posts_recycle_bar'); if (bar) bar.hidden = !postHistoryRecycleMode; if (postHistoryRecycleMode) { renderPostHistoryRecycleView(); } else { renderPostHistoryModule(); } } function renderPostHistoryModule(query) { if (postHistoryRecycleMode) { renderPostHistoryRecycleView(); return; } updatePostHistoryRecycleBadge(); const root = document.getElementById('sp_posts_results'); if (!root) return; postHistoryLiveRenderDirty = false; invalidateHistoryRenderCaches(); const input = document.getElementById('sp_posts_search'); const effectiveQuery = query == null && input ? input.value : query; const results = searchPostHistory(effectiveQuery || '', postHistoryActiveType); const count = document.getElementById('sp_posts_count'); if (count) { count.textContent = `${results.length} 条 `; count.appendChild(buildHistorySearchHelpMark(POST_HISTORY_SEARCH_HELP_TEXT)); } root.textContent = ''; if (!results.length) { const empty = document.createElement('div'); empty.className = 'xdex-history-empty xdex-post-history-empty'; empty.textContent = effectiveQuery ? '没有匹配的我的发言' : (postHistoryActiveType === 'reply' ? '暂无我的回复' : '暂无我的主题'); root.appendChild(empty); return; } batchRenderHistoryItems(root, results, buildPostHistoryItemElement, 'postHistory'); } function renderPostHistoryModuleSoon(query) { postHistoryLiveRenderDirty = false; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => renderPostHistoryModule(query)); return; } setTimeout(() => renderPostHistoryModule(query), 0); } // ─── 订阅 Feed 渲染 ────────────────────────────────────────────────────── const SUBSCRIPTION_FEED_API_BASE = 'https://api.nmb.best/api'; const SUBSCRIPTION_FEED_SESSION_STORE_KEY = 'xdex_subscription_feed_sessions_v3'; const SUBSCRIPTION_FEED_SESSION_VERSION = 3; const SUBSCRIPTION_FEED_SESSION_TTL_MS = 2 * 60 * 1000; const SUBSCRIPTION_FEED_SESSION_MAX_COUNT = 5; let subscriptionFeedCurrentPage = 1; let subscriptionFeedCurrentUuid = ''; let subscriptionFeedLoading = false; let subscriptionFeedAllItems = []; let subscriptionFeedHasMore = true; let subscriptionFeedRequestSeq = 0; const subscriptionFeedInflightPages = Object.create(null); let subscriptionFeedRenderedPages = Object.create(null); let subscriptionFeedHighestRenderedPage = 0; let subscriptionFeedCurrentDisplayPage = 1; let subscriptionFeedCacheExpired = false; const ACTIVE_FEED_STORAGE_KEY = 'xdex_active_subscription_feed_uuid'; function getActiveSubscriptionFeedUuid() { try { return GM_getValue(ACTIVE_FEED_STORAGE_KEY, ''); } catch (e) { return ''; } } function setActiveSubscriptionFeedUuid(uuid) { try { GM_setValue(ACTIVE_FEED_STORAGE_KEY, uuid || ''); } catch (e) {} } function createDefaultSubscriptionFeedSessionStore() { return { version: SUBSCRIPTION_FEED_SESSION_VERSION, sessions: {} }; } function normalizeSubscriptionFeedSessionStore(rawStore) { const store = Object.assign(createDefaultSubscriptionFeedSessionStore(), rawStore || {}); store.version = SUBSCRIPTION_FEED_SESSION_VERSION; store.sessions = store.sessions && typeof store.sessions === 'object' ? store.sessions : {}; return store; } function getSubscriptionFeedSessionStore() { try { return normalizeSubscriptionFeedSessionStore(GM_getValue(SUBSCRIPTION_FEED_SESSION_STORE_KEY, null)); } catch (e) { return createDefaultSubscriptionFeedSessionStore(); } } function setSubscriptionFeedSessionStore(store) { const normalized = normalizeSubscriptionFeedSessionStore(store); GM_setValue(SUBSCRIPTION_FEED_SESSION_STORE_KEY, normalized); return normalized; } function getSubscriptionFeedItemId(item) { return String(Number(item && item.id) || 0); } function normalizeSubscriptionFeedPageItems(items) { const list = Array.isArray(items) ? items : []; const seen = new Set(); return list.filter((item) => { const id = getSubscriptionFeedItemId(item); if (!id || id === '0' || seen.has(id)) return false; seen.add(id); return true; }); } function buildSubscriptionFeedPageEntry(page, items) { const normalizedPage = Math.max(1, Number(page) || 1); const normalizedItems = normalizeSubscriptionFeedPageItems(items); return { page: normalizedPage, fetchedAt: Date.now(), itemIds: normalizedItems.map(getSubscriptionFeedItemId), items: normalizedItems }; } function computeHighestContiguousSubscriptionFeedPage(pages) { let page = 1; while (pages && pages[String(page)] && Number(pages[String(page)].page) === page) page++; return page - 1; } function normalizeSubscriptionFeedSession(session) { if (!session || typeof session !== 'object') return null; const uuid = String(session.uuid || '').trim(); if (!uuid) return null; const cachedPages = session.cachedPages && typeof session.cachedPages === 'object' ? session.cachedPages : {}; const normalizedPages = {}; Object.keys(cachedPages).forEach((key) => { const entry = cachedPages[key] || {}; const page = Math.max(1, Number(entry.page || key) || 1); const normalizedItems = normalizeSubscriptionFeedPageItems(entry.items); normalizedPages[String(page)] = { page, fetchedAt: Number(entry.fetchedAt) || 0, itemIds: normalizedItems.map(getSubscriptionFeedItemId), items: normalizedItems }; }); const highestCachedPage = computeHighestContiguousSubscriptionFeedPage(normalizedPages); const createdAt = Number(session.createdAt) || 0; const expiresAt = Number(session.expiresAt) || 0; const lastAccessAt = Number(session.lastAccessAt) || createdAt || Date.now(); return { version: SUBSCRIPTION_FEED_SESSION_VERSION, uuid, createdAt, expiresAt, lastAccessAt, highestCachedPage, cachedPages: normalizedPages }; } function isSubscriptionFeedSessionExpired(session) { return !session || !session.expiresAt || Date.now() > Number(session.expiresAt); } function isSubscriptionFeedSessionStructurallyValid(session) { const normalized = normalizeSubscriptionFeedSession(session); if (!normalized) return false; if (!normalized.createdAt || !normalized.expiresAt) return false; for (let page = 1; page <= normalized.highestCachedPage; page++) { const entry = normalized.cachedPages[String(page)]; if (!entry || Number(entry.page) !== page || !Array.isArray(entry.items)) return false; } return true; } function getSubscriptionFeedSession(uuid) { const key = String(uuid || '').trim(); if (!key) return null; const store = getSubscriptionFeedSessionStore(); return normalizeSubscriptionFeedSession(store.sessions[key]); } function saveSubscriptionFeedSession(session) { const normalized = normalizeSubscriptionFeedSession(session); if (!normalized) return null; const store = getSubscriptionFeedSessionStore(); store.sessions[normalized.uuid] = normalized; const ordered = Object.values(store.sessions) .map(normalizeSubscriptionFeedSession) .filter(Boolean) .sort((a, b) => (Number(b.lastAccessAt) || 0) - (Number(a.lastAccessAt) || 0)); const limited = ordered.slice(0, SUBSCRIPTION_FEED_SESSION_MAX_COUNT); store.sessions = {}; limited.forEach((item) => { store.sessions[item.uuid] = item; }); setSubscriptionFeedSessionStore(store); return normalized; } function deleteSubscriptionFeedSession(uuid) { const key = String(uuid || '').trim(); if (!key) return; const store = getSubscriptionFeedSessionStore(); if (!store.sessions[key]) return; delete store.sessions[key]; setSubscriptionFeedSessionStore(store); } function pruneExpiredSubscriptionFeedSessions() { const store = getSubscriptionFeedSessionStore(); let changed = false; Object.keys(store.sessions).forEach((uuid) => { const session = normalizeSubscriptionFeedSession(store.sessions[uuid]); if (!session || isSubscriptionFeedSessionExpired(session) || !isSubscriptionFeedSessionStructurallyValid(session)) { delete store.sessions[uuid]; changed = true; return; } store.sessions[uuid] = session; }); if (changed) setSubscriptionFeedSessionStore(store); } function createSubscriptionFeedSession(uuid, firstPageEntry) { const now = Date.now(); const normalizedUuid = String(uuid || '').trim(); const cachedPages = {}; let highestCachedPage = 0; if (firstPageEntry && Array.isArray(firstPageEntry.items) && firstPageEntry.items.length) { cachedPages[String(firstPageEntry.page)] = firstPageEntry; highestCachedPage = firstPageEntry.page === 1 ? 1 : 0; } return { version: SUBSCRIPTION_FEED_SESSION_VERSION, uuid: normalizedUuid, createdAt: now, expiresAt: now + SUBSCRIPTION_FEED_SESSION_TTL_MS, lastAccessAt: now, highestCachedPage, cachedPages }; } function flattenSubscriptionFeedSessionItems(session) { const normalized = normalizeSubscriptionFeedSession(session); if (!normalized || normalized.highestCachedPage <= 0) return []; const items = []; for (let page = 1; page <= normalized.highestCachedPage; page++) { const entry = normalized.cachedPages[String(page)]; if (!entry || !Array.isArray(entry.items)) break; items.push(...entry.items); } return items; } function resetSubscriptionFeedRuntimeState(uuid) { subscriptionFeedCurrentUuid = String(uuid || '').trim(); subscriptionFeedCurrentPage = 0; subscriptionFeedAllItems = []; subscriptionFeedHasMore = true; subscriptionFeedRenderedPages = Object.create(null); subscriptionFeedHighestRenderedPage = 0; subscriptionFeedCurrentDisplayPage = 1; subscriptionFeedCacheExpired = false; } function rebuildSubscriptionFeedRuntimeList() { const items = []; for (let page = 1; page <= subscriptionFeedHighestRenderedPage; page++) { const entry = subscriptionFeedRenderedPages[String(page)]; if (!entry || !Array.isArray(entry.items)) break; items.push(...entry.items); } subscriptionFeedAllItems = items; subscriptionFeedCurrentPage = subscriptionFeedHighestRenderedPage; } function applySubscriptionFeedSessionToRuntime(session) { const normalized = normalizeSubscriptionFeedSession(session); if (!normalized) return false; subscriptionFeedCurrentUuid = normalized.uuid; subscriptionFeedRenderedPages = Object.create(null); for (let page = 1; page <= normalized.highestCachedPage; page++) { const entry = normalized.cachedPages[String(page)]; if (!entry) break; subscriptionFeedRenderedPages[String(page)] = entry; } subscriptionFeedHighestRenderedPage = normalized.highestCachedPage; subscriptionFeedCurrentDisplayPage = normalized.highestCachedPage > 0 ? 1 : 0; rebuildSubscriptionFeedRuntimeList(); subscriptionFeedHasMore = true; subscriptionFeedCacheExpired = false; return true; } function appendSubscriptionFeedRenderedPage(pageEntry) { if (!pageEntry || !pageEntry.page || !Array.isArray(pageEntry.items)) return false; const page = Math.max(1, Number(pageEntry.page) || 1); if (page !== subscriptionFeedHighestRenderedPage + 1) return false; subscriptionFeedRenderedPages[String(page)] = pageEntry; subscriptionFeedHighestRenderedPage = page; subscriptionFeedCurrentDisplayPage = page; rebuildSubscriptionFeedRuntimeList(); return true; } function getSubscriptionFeedNextRenderPage() { return Math.max(0, Number(subscriptionFeedHighestRenderedPage) || 0) + 1; } function appendSubscriptionFeedPageSeparator(root, page) { if (!root || !page || page <= 1) return; const sep = document.createElement('div'); sep.className = 'xdex-feed-page-separator'; sep.style.cssText = 'text-align:center;color:#999;font-size:12px;padding:8px 0;border-top:1px dashed #ddd;margin-top:8px;'; sep.textContent = `——第${page}页——`; root.appendChild(sep); } function updateSubscriptionFeedDisplayPageFromScroll() { const container = document.querySelector('#sp_module_feeds .sp_panel_content'); const results = document.getElementById('sp_feeds_results'); if (!container || !results || subscriptionFeedHighestRenderedPage <= 0) return; const separators = Array.from(results.querySelectorAll('.xdex-feed-page-separator')); let displayPage = 1; const threshold = container.scrollTop + container.clientHeight / 2; separators.forEach((sep) => { const page = Number((sep.textContent || '').match(/第(\d+)页/)?.[1] || 0); if (!page) return; const top = sep.offsetTop; if (threshold >= top) displayPage = page; }); subscriptionFeedCurrentDisplayPage = Math.min(Math.max(1, displayPage), Math.max(1, subscriptionFeedHighestRenderedPage)); $('#sp_feeds_page_label').text(`第${subscriptionFeedCurrentDisplayPage}页`); } function renderSubscriptionFeedRenderedPages(options = {}) { const $results = $('#sp_feeds_results').empty(); const displayPage = Math.max(0, Number(options.displayPage) || 0) || subscriptionFeedCurrentDisplayPage || subscriptionFeedHighestRenderedPage || 0; if (subscriptionFeedHighestRenderedPage <= 0) { $results.html('<div style="text-align:center;color:#999;padding:40px 0;">暂无订阅内容</div>'); $('#sp_feeds_page_label').text('第0页'); return; } for (let page = 1; page <= subscriptionFeedHighestRenderedPage; page++) { const entry = subscriptionFeedRenderedPages[String(page)]; if (!entry || !Array.isArray(entry.items)) break; appendSubscriptionFeedPageSeparator($results[0], page); entry.items.forEach((item) => { $results[0].appendChild(buildSubscriptionFeedItemElement(item)); }); } subscriptionFeedCurrentDisplayPage = Math.min(Math.max(1, displayPage), Math.max(1, subscriptionFeedHighestRenderedPage)); $('#sp_feeds_page_label').text(`第${subscriptionFeedCurrentDisplayPage}页`); if (typeof requestAnimationFrame === 'function') requestAnimationFrame(updateSubscriptionFeedDisplayPageFromScroll); else setTimeout(updateSubscriptionFeedDisplayPageFromScroll, 0); } function restoreSubscriptionFeedSession(uuid) { const session = getSubscriptionFeedSession(uuid); if (!session || isSubscriptionFeedSessionExpired(session) || !isSubscriptionFeedSessionStructurallyValid(session)) { if (session) deleteSubscriptionFeedSession(uuid); return false; } const restored = Object.assign({}, session, { lastAccessAt: Date.now() }); saveSubscriptionFeedSession(restored); applySubscriptionFeedSessionToRuntime(restored); renderSubscriptionFeedRenderedPages({ displayPage: 1 }); return true; } function invalidateSubscriptionFeedSession(uuid, reason) { const key = String(uuid || '').trim(); if (!key) return; console.info('[subscription-feed] invalidate session', { uuid: key, reason: reason || '' }); deleteSubscriptionFeedSession(key); } function populateSubscriptionFeedSelector() { const $sel = $('#sp_feeds_selector').empty(); const $display = $('#sp_feeds_selector_display'); const $dropdown = $('#sp_feeds_selector_dropdown').empty().hide(); const $desc = $display.find('.xdex-feed-display-desc'); const $uuid = $display.find('.xdex-feed-display-uuid'); const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; if (!feeds.length) { $sel.append('<option value="">(请先添加订阅号)</option>'); $desc.text('请先在设置中添加订阅号'); $uuid.text(''); return ''; } feeds.forEach((f, i) => { const label = f.desc ? `${f.desc}:${f.uuid}` : f.uuid; $sel.append(`<option value="${Utils.escapeHTML ? Utils.escapeHTML(f.uuid) : f.uuid}">${Utils.escapeHTML ? Utils.escapeHTML(label) : label}</option>`); const $opt = $('<div class="xdex-feed-option" role="option"></div>') .attr('data-uuid', f.uuid) .text(label); $dropdown.append($opt); }); const saved = getActiveSubscriptionFeedUuid(); const match = feeds.find(f => f.uuid === saved); const selected = match ? match.uuid : feeds[0].uuid; $sel.val(selected); const activeFeed = feeds.find(f => f.uuid === selected); $desc.text(activeFeed && activeFeed.desc ? activeFeed.desc : ''); // 无备注时也要显示订阅号(打码),避免选择框看起来是空的 $uuid.text(activeFeed && activeFeed.uuid ? activeFeed.uuid : ''); $uuid.toggleClass('xdex-feed-uuid-light-blur', !!(activeFeed && activeFeed.uuid && !activeFeed.desc)); $dropdown.find('.xdex-feed-option').removeClass('active').filter(`[data-uuid="${selected}"]`).addClass('active'); return selected; } function buildSubscriptionFeedItemElement(item) { const threadId = Number(item.id) || 0; const wrapper = document.createElement('div'); wrapper.className = 'xdex-history-item'; wrapper.dataset.feedThreadId = String(threadId); const main = document.createElement('div'); main.className = 'h-threads-item-main'; wrapper.appendChild(main); const info = document.createElement('div'); info.className = 'h-threads-info xdex-history-info'; main.appendChild(info); const infoMain = document.createElement('span'); infoMain.className = 'xdex-history-info-main'; info.appendChild(infoMain); const title = String(item.title || ''); const email = String(item.email || ''); const now = String(item.now || ''); const userHash = String(item.user_hash || ''); if (title && title !== '无标题') appendThreadHistoryInfoText(infoMain, 'h-threads-info-title', title); if (email) appendThreadHistoryInfoText(infoMain, 'h-threads-info-email', email); const createdAtNode = appendThreadHistoryInfoText(infoMain, 'h-threads-info-createdat', now); if (createdAtNode) { createdAtNode.dataset.xdexOriginalTime = now; createdAtNode.title = now; } if (userHash) { const cookieSpan = appendThreadHistoryText(infoMain, 'span', 'h-threads-info-uid', `ID:${userHash}`); if (cookieSpan) cookieSpan.setAttribute('data-xdex-cookie-id', userHash); } const replyLink = document.createElement('a'); replyLink.className = 'h-threads-info-id xdex-history-thread-id'; replyLink.href = `${location.origin}/t/${threadId}`; replyLink.textContent = `No.${threadId}`; infoMain.appendChild(replyLink); const replyCount = Number(item.reply_count) || 0; if (replyCount > 0) { appendThreadHistoryText(infoMain, 'span', 'xdex-history-visit-count', `${replyCount} 回`); } // 从浏览历史查找最近查看页 const tid = String(threadId || '').trim(); const histStore = getThreadHistoryStore(); const histCandidates = ['normal', 'po'] .map((mode) => histStore.items[getThreadHistoryKey(mode, tid)]) .filter(Boolean) .sort((a, b) => (Number(b.lastVisitedAt) || 0) - (Number(a.lastVisitedAt) || 0)); const histItem = histCandidates[0] || null; const histPage = histItem ? (Number(histItem.page) || 1) : 1; // [回应] 链接 const replyAction = document.createElement('span'); replyAction.className = 'h-threads-info-reply-btn xdex-history-reply-label'; const replyActionLink = document.createElement('a'); replyActionLink.className = 'xdex-history-reply-action'; replyActionLink.href = `${location.origin}/t/${tid}?page=${histPage}`; replyActionLink.target = '_blank'; replyActionLink.rel = 'noopener'; replyActionLink.textContent = '回应'; replyAction.appendChild(document.createTextNode('[')); replyAction.appendChild(replyActionLink); replyAction.appendChild(document.createTextNode(']')); infoMain.appendChild(replyAction); // 取消订阅按钮 const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'xdex-post-history-delete'; deleteButton.dataset.feedThreadId = String(threadId); deleteButton.title = '取消订阅'; deleteButton.innerHTML = XDEX_SVG_X; main.appendChild(deleteButton); // 图片 const imgRaw = String(item.img || ''); const extRaw = String(item.ext || ''); if (imgRaw) { const suffix = extRaw ? (extRaw[0] === '.' ? extRaw : `.${extRaw}`) : ''; const imageFile = suffix && imgRaw.toLowerCase().endsWith(suffix.toLowerCase()) ? imgRaw : imgRaw + suffix; const imageLink = document.createElement('a'); imageLink.className = 'h-threads-img-a xdex-history-image'; imageLink.dataset.historyQuoteId = threadId; imageLink.href = buildThreadHistoryImageUrl(imageFile, true); imageLink.target = '_blank'; imageLink.rel = 'noopener'; const img = document.createElement('img'); img.className = 'h-threads-img'; img.src = buildThreadHistoryImageUrl(imageFile, false); img.alt = imageFile; imageLink.appendChild(img); main.appendChild(imageLink); } // 正文 const content = document.createElement('div'); content.className = 'h-threads-content'; const contentHtml = String(item.content || ''); if (contentHtml) content.innerHTML = contentHtml; else content.textContent = ''; main.appendChild(content); enhanceHistoryRenderedContent(content); // 脚注 const footer = document.createElement('div'); footer.className = 'xdex-history-footer'; const fid = String(item.fid || ''); const forumName = fid ? (POST_HISTORY_FORUM_FID_MAP[fid] || '') : ''; if (forumName) appendThreadHistoryText(footer, 'span', 'xdex-post-history-forum', forumName); appendThreadHistoryText(footer, 'span', 'xdex-post-history-type', '订阅'); appendThreadHistoryText(footer, 'span', 'xdex-history-time', now); main.appendChild(footer); enhanceHistoryRenderedContent(footer); markAllCookies(getFilterConfig().markedGroups || [], wrapper); return wrapper; } async function fetchSubscriptionFeedPage(uuid, page) { const url = `${SUBSCRIPTION_FEED_API_BASE}/feed?uuid=${encodeURIComponent(uuid)}&page=${encodeURIComponent(page)}`; const resp = await gmRequest(url, 'json'); const data = resp.response || resp.responseText; if (Array.isArray(data)) return data; try { return JSON.parse(typeof data === 'string' ? data : '[]'); } catch (e) { return []; } } function renderSubscriptionFeedModule() { const $results = $('#sp_feeds_results').empty(); pruneExpiredSubscriptionFeedSessions(); const uuid = populateSubscriptionFeedSelector(); if (!uuid) { $results.html('<div style="text-align:center;color:#999;padding:40px 0;">请先在设置中添加订阅号</div>'); subscriptionFeedCurrentUuid = ''; subscriptionFeedHasMore = false; return; } subscriptionFeedCurrentUuid = uuid; if (restoreSubscriptionFeedSession(uuid)) return; resetSubscriptionFeedRuntimeState(uuid); $results.html('<div style="text-align:center;color:#999;padding:40px 0;">正在获取订阅……</div>'); loadSubscriptionFeedPage(uuid, 1, { replace: true, source: 'init' }); } async function loadSubscriptionFeedPage(uuid, page, options = {}) { const replace = !!options.replace; if (!uuid || subscriptionFeedLoading) return; const requestPage = Math.max(1, Number(page) || 1); const $results = $('#sp_feeds_results'); if (replace && subscriptionFeedHighestRenderedPage <= 0) { resetSubscriptionFeedRuntimeState(uuid); if ($results.length) $results.empty(); } const expectedPage = getSubscriptionFeedNextRenderPage(); if (requestPage !== expectedPage) { console.warn('[subscription-feed] reject non-contiguous page', { uuid, page: requestPage, expectedPage, source: options.source || '', currentPage: subscriptionFeedCurrentPage, highestRenderedPage: subscriptionFeedHighestRenderedPage, allItems: subscriptionFeedAllItems.length, cacheExpired: subscriptionFeedCacheExpired }); return; } const session = getSubscriptionFeedSession(uuid); if (session && isSubscriptionFeedSessionExpired(session)) subscriptionFeedCacheExpired = true; subscriptionFeedLoading = true; const seq = ++subscriptionFeedRequestSeq; subscriptionFeedInflightPages[requestPage] = seq; try { const items = normalizeSubscriptionFeedPageItems(await fetchSubscriptionFeedPage(uuid, requestPage)); if (subscriptionFeedInflightPages[requestPage] !== seq) return; if (uuid !== subscriptionFeedCurrentUuid) return; if (!items.length) { subscriptionFeedHasMore = false; renderSubscriptionFeedRenderedPages({ displayPage: subscriptionFeedCurrentDisplayPage }); return; } const pageEntry = buildSubscriptionFeedPageEntry(requestPage, items); if (!appendSubscriptionFeedRenderedPage(pageEntry)) return; subscriptionFeedHasMore = true; renderSubscriptionFeedRenderedPages({ displayPage: subscriptionFeedCurrentDisplayPage }); if (!subscriptionFeedCacheExpired) { let nextSession = getSubscriptionFeedSession(uuid); if (!nextSession || isSubscriptionFeedSessionExpired(nextSession) || !isSubscriptionFeedSessionStructurallyValid(nextSession)) { nextSession = createSubscriptionFeedSession(uuid, pageEntry); } else { nextSession.cachedPages[String(requestPage)] = pageEntry; nextSession.highestCachedPage = computeHighestContiguousSubscriptionFeedPage(nextSession.cachedPages); nextSession.lastAccessAt = Date.now(); } saveSubscriptionFeedSession(nextSession); } } catch (err) { console.error('[subscription-feed] load error', err); if (replace) { $results.html(`<div style="text-align:center;color:#c00;padding:40px 0;">加载失败:${Utils.escapeHTML ? Utils.escapeHTML(err.message) : err.message}</div>`); } } finally { if (subscriptionFeedInflightPages[requestPage] === seq) delete subscriptionFeedInflightPages[requestPage]; subscriptionFeedLoading = false; } } function bindSubscriptionFeedModuleEvents() { // 订阅号切换 // 自定义下拉菜单交互 const $wrap = $('.xdex-feed-selector-wrap'); const $display = $('#sp_feeds_selector_display'); const $dropdown = $('#sp_feeds_selector_dropdown'); // 鼠标悬停时临时显示 UUID,移出后恢复模糊(比 CSS :hover 更可靠) const $uuid = $display.find('.xdex-feed-display-uuid'); $wrap.off('mouseenter.feedBlur mouseleave.feedBlur') .on('mouseenter.feedBlur', () => $uuid.css('filter', 'none')) .on('mouseleave.feedBlur', () => $uuid.css('filter', '')); $display.off('click.feedDropdown').on('click.feedDropdown', (e) => { e.stopPropagation(); const isOpen = $dropdown.is(':visible'); $dropdown.toggle(!isOpen); $display.attr('aria-expanded', String(!isOpen)); }); $dropdown.off('click.feedOption', '.xdex-feed-option').on('click.feedOption', '.xdex-feed-option', function (e) { e.stopPropagation(); const uuid = $(this).data('uuid') || ''; if (!uuid) return; $('#sp_feeds_selector').val(uuid).trigger('change.subscriptionFeed'); const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; const feed = feeds.find(f => f.uuid === uuid); const $uuidEl = $display.find('.xdex-feed-display-uuid'); $display.find('.xdex-feed-display-desc').text(feed && feed.desc ? feed.desc : ''); // 无备注时也显示打码订阅号 $uuidEl.text(feed && feed.uuid ? feed.uuid : ''); $uuidEl.toggleClass('xdex-feed-uuid-light-blur', !!(feed && feed.uuid && !feed.desc)); $dropdown.find('.xdex-feed-option').removeClass('active').filter('[data-uuid="' + uuid + '"]').addClass('active'); $dropdown.hide(); $display.attr('aria-expanded', 'false'); }); $(document).off('click.feedDropdownClose').on('click.feedDropdownClose', () => { $dropdown.hide(); $display.attr('aria-expanded', 'false'); }); $('#sp_feeds_selector').off('change.subscriptionFeed').on('change.subscriptionFeed', function () { subscriptionFeedCurrentUuid = $(this).val() || ''; if (subscriptionFeedCurrentUuid) { setActiveSubscriptionFeedUuid(subscriptionFeedCurrentUuid); renderSubscriptionFeedModule(); } }); // 跨页面同步:其他标签页切换订阅号时自动刷新 if (typeof GM_addValueChangeListener === 'function') { GM_addValueChangeListener(ACTIVE_FEED_STORAGE_KEY, (_key, _oldVal, newVal, remote) => { if (!remote) return; const uuid = String(newVal || ''); if (uuid && uuid !== subscriptionFeedCurrentUuid) { subscriptionFeedCurrentUuid = uuid; $('#sp_feeds_selector').val(uuid); renderSubscriptionFeedModule(); } }); } // 跳转 $('#sp_feeds_page_jump').off('click.subscriptionFeed').on('click.subscriptionFeed', (e) => { e.preventDefault(); const page = parseInt($('#sp_feeds_page_input').val(), 10); if (!page || page < 1 || !subscriptionFeedCurrentUuid) return; const expectedPage = getSubscriptionFeedNextRenderPage(); if (page !== expectedPage) { toast(`当前缓存会话仅支持连续翻页,请先加载第${expectedPage}页`); return; } loadSubscriptionFeedPage(subscriptionFeedCurrentUuid, page, { replace: false, source: 'jump' }); }); // 滚动加载下一页 const $scrollContainer = $('#sp_module_feeds .sp_panel_content'); $scrollContainer.off('scroll.subscriptionFeed').on('scroll.subscriptionFeed', function () { updateSubscriptionFeedDisplayPageFromScroll(); if (subscriptionFeedLoading || !subscriptionFeedHasMore || !subscriptionFeedCurrentUuid) return; const el = this; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 400) { const nextPage = getSubscriptionFeedNextRenderPage(); loadSubscriptionFeedPage(subscriptionFeedCurrentUuid, nextPage, { replace: false, source: 'scroll' }); } }); // 上一页 $('#sp_feeds_prev').off('click.subscriptionFeed').on('click.subscriptionFeed', (e) => { e.preventDefault(); toast('当前缓存会话仅支持向后连续翻页'); }); // 下一页 $('#sp_feeds_next').off('click.subscriptionFeed').on('click.subscriptionFeed', (e) => { e.preventDefault(); if (!subscriptionFeedCurrentUuid) return; const nextPage = getSubscriptionFeedNextRenderPage(); loadSubscriptionFeedPage(subscriptionFeedCurrentUuid, nextPage, { replace: false, source: 'next-button' }); }); // 订阅面板图片点击 → 打开引用弹窗(图片激活态) $('#sp_feeds_results').off('click.xdex-feed-image-quote', '.xdex-history-image').on('click.xdex-feed-image-quote', '.xdex-history-image', function (e) { if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; window.__xdexPendingQuoteCtxTid = (window.__xdexQuoteRefMark && window.__xdexQuoteRefMark.getContextThreadId(this)) || ''; const opened = openHistoryImageQuotePreview(this.dataset.historyQuoteId || ''); if (!opened) return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }); // 取消订阅 $('#sp_feeds_results').off('click.xdex-feed-delete', '.xdex-post-history-delete').on('click.xdex-feed-delete', '.xdex-post-history-delete', function (e) { e.preventDefault(); const tid = this.dataset.feedThreadId || ''; if (!tid || !subscriptionFeedCurrentUuid) return; if (!window.confirm('确定要取消订阅这个串吗?')) return; GM_xmlhttpRequest({ method: 'POST', url: `${SUBSCRIPTION_FEED_API_BASE}/delFeed`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `uuid=${encodeURIComponent(subscriptionFeedCurrentUuid)}&tid=${encodeURIComponent(tid)}`, onload: () => { invalidateSubscriptionFeedSession(subscriptionFeedCurrentUuid, 'delete-feed'); resetSubscriptionFeedRuntimeState(subscriptionFeedCurrentUuid); toast('已取消订阅'); renderSubscriptionFeedModule(); }, onerror: () => toast('取消订阅失败') }); }); // ── 批量添加订阅 ── $('#sp_feeds_bulk_add_input').attr('autocomplete', 'off'); $('#sp_feeds_bulk_add_btn').off('click.subscriptionFeedBulkAdd').on('click.subscriptionFeedBulkAdd', (e) => { e.preventDefault(); const uuid = subscriptionFeedCurrentUuid || resolveSubscriptionFeedUuid(); if (!uuid) { toast('请先在设置中添加一个订阅号'); return; } const raw = ($('#sp_feeds_bulk_add_input').val() || '').trim(); if (!raw) { toast('请输入串号'); return; } const tids = raw.split(/[,,\s]+/).map(s => s.replace(/^(?:No\.)/i, '').trim()).filter(s => /^\d{6,8}$/.test(s)); if (!tids.length) { toast('未识别到有效串号'); return; } const $btn = $(e.currentTarget); $btn.prop('disabled', true).text('添加中…'); let success = 0, fail = 0, idx = 0; const failedTids = []; const BATCH_DELAY = 300; function next() { if (idx >= tids.length) { $btn.prop('disabled', false).text('批量添加'); invalidateSubscriptionFeedSession(uuid, 'bulk-add'); renderSubscriptionFeedModule(); toast(`批量添加完成:成功 ${success},失败 ${fail},共 ${tids.length} 条`); if (failedTids.length) { $('#sp_feeds_bulk_add_input').val(failedTids.join(',')); } else { $('#sp_feeds_bulk_add_input').val(''); } return; } const tid = tids[idx++]; GM_xmlhttpRequest({ method: 'POST', url: `${SUBSCRIPTION_FEED_API_BASE}/addFeed`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `uuid=${encodeURIComponent(uuid)}&tid=${encodeURIComponent(tid)}`, onload: (resp) => { try { const raw = String(resp.responseText || resp.response || ''); const msg = JSON.parse(raw); if (typeof msg === 'string' && /不存在|失败|错误/.test(msg)) { fail++; failedTids.push(tid); } else success++; } catch (_) { success++; } setTimeout(next, BATCH_DELAY); }, onerror: () => { fail++; failedTids.push(tid); setTimeout(next, BATCH_DELAY); } }); } next(); }); // ── 导出串号到剪贴板 ── $('#sp_feeds_export_clipboard').off('click.subscriptionFeedExport').on('click.subscriptionFeedExport', (e) => { e.preventDefault(); const uuid = subscriptionFeedCurrentUuid || resolveSubscriptionFeedUuid(); if (!uuid) { toast('请先在设置中添加一个订阅号'); return; } const $btn = $(e.currentTarget); $btn.prop('disabled', true).text('导出中…'); const allTids = []; let page = 1; const PAGE_DELAY = 500; function fetchNext() { fetchSubscriptionFeedPage(uuid, page).then(items => { if (!items || !items.length) { // 全部拉完 $btn.prop('disabled', false).text('导出串号'); if (!allTids.length) { toast('该订阅号下无订阅内容'); return; } const text = allTids.join(','); navigator.clipboard.writeText(text).then(() => { toast(`已导出 ${allTids.length} 个串号到剪贴板`); }).catch(() => { toast('导出失败,无法写入剪贴板'); }); return; } items.forEach(item => { const id = String(item.id || '').trim(); if (id && id !== '0' && !allTids.includes(id)) allTids.push(id); }); page++; setTimeout(fetchNext, PAGE_DELAY); }).catch(() => { $btn.prop('disabled', false).text('导出串号'); toast('导出失败,网络错误'); }); } fetchNext(); }); } function setPostHistoryType(type) { postHistoryActiveType = normalizePostHistoryType(type); $('#sp_posts_type_buttons [data-post-history-type]').removeClass('active') .filter(`[data-post-history-type="${postHistoryActiveType}"]`).addClass('active'); renderPostHistoryModule(); } function bindPostHistoryModuleEvents() { bindHistorySearchInput('#sp_posts_search', renderPostHistoryModule, 'xdex-post-history'); $('#sp_posts_type_buttons').off('click.xdex-post-history', '[data-post-history-type]').on('click.xdex-post-history', '[data-post-history-type]', function (e) { e.preventDefault(); setPostHistoryType(this.dataset.postHistoryType || 'thread'); }); $('#sp_posts_results').off('click.xdex-post-history-reply', '.xdex-post-history-reply-action').on('click.xdex-post-history-reply', '.xdex-post-history-reply-action', function (e) { if (e.button !== 0) return; const url = this.href || ''; if (!url) return; e.preventDefault(); if (e.ctrlKey || e.metaKey) { window.location.href = url; return; } window.open(url, '_blank', 'noopener'); }); $('#sp_posts_results').off('click.xdex-post-history-image-quote', '.xdex-post-history-image').on('click.xdex-post-history-image-quote', '.xdex-post-history-image', function (e) { if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; window.__xdexPendingQuoteCtxTid = (window.__xdexQuoteRefMark && window.__xdexQuoteRefMark.getContextThreadId(this)) || ''; const opened = openHistoryImageQuotePreview(this.dataset.postHistoryQuoteId || ''); if (!opened) return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }); $('#sp_posts_results').off('click.xdex-post-history-delete', '.xdex-post-history-delete').on('click.xdex-post-history-delete', '.xdex-post-history-delete', function (e) { e.preventDefault(); const key = this.dataset.postHistoryKey || ''; if (!key) return; if (!window.confirm('确定要删除这条发言记录吗?')) return; deletePostHistoryItem(key); renderPostHistoryModule(); toast('已移入回收站,30 天后自动清除,可在回收站恢复'); }); $('#sp_posts_clear').off('click.xdex-post-history').on('click.xdex-post-history', function (e) { e.preventDefault(); showClearHistoryWarningDialog(function () { if (!window.confirm('确定要清空全部我的发言记录吗?')) return; clearPostHistory(); renderPostHistoryModule(); toast('已清空我的发言(已移入回收站,30 天内可恢复)'); }); }); $('#sp_posts_recycle').off('click.xdex-post-history-recycle').on('click.xdex-post-history-recycle', function (e) { e.preventDefault(); setPostHistoryRecycleMode(true); }); $('#sp_posts_recycle_back').off('click.xdex-post-history-recycle').on('click.xdex-post-history-recycle', function (e) { e.preventDefault(); setPostHistoryRecycleMode(false); }); $('#sp_posts_recycle_sort').off('change.xdex-post-history-recycle').on('change.xdex-post-history-recycle', function () { postHistoryRecycleSort = this.value; renderPostHistoryRecycleView(); }); $('#sp_posts_results').off('click.xdex-post-history-recycle-action', '.xdex-recycle-item-restore').on('click.xdex-post-history-recycle-action', '.xdex-recycle-item-restore', function (e) { e.preventDefault(); e.stopPropagation(); const key = this.dataset.recycleKey || ''; if (!key) return; restorePostHistoryFromTombstone(key); renderPostHistoryRecycleView(); toast('已恢复该发言记录'); }); $('#sp_posts_results').off('click.xdex-post-history-recycle-action', '.xdex-recycle-item-purge').on('click.xdex-post-history-recycle-action', '.xdex-recycle-item-purge', function (e) { e.preventDefault(); e.stopPropagation(); const key = this.dataset.recycleKey || ''; if (!key) return; if (!window.confirm('确定要彻底删除这条记录吗?此操作无法通过恢复找回。')) return; purgePostHistoryTombstone(key); renderPostHistoryRecycleView(); toast('已彻底删除'); }); $('#sp_posts_recycle_empty').off('click.xdex-post-history-recycle-empty').on('click.xdex-post-history-recycle-empty', function (e) { e.preventDefault(); if (this.disabled) return; if (!window.confirm('确定要清空回收站吗?全部待删除记录将被彻底清除,无法恢复。')) return; purgeAllPostHistoryTombstones(); renderPostHistoryRecycleView(); toast('回收站已清空'); }); // 手动添加发言历史 // 禁用浏览器自动填充 $('#sp_posts_manual_add_input').attr('autocomplete', 'off'); $('#sp_posts_manual_add_btn').off('click.xdex-post-history-manual').on('click.xdex-post-history-manual', function (e) { e.preventDefault(); const raw = ($('#sp_posts_manual_add_input').val() || '').trim(); // 支持格式:No.67024789、67024789、https://nmbxd1.com/t/67024789、67024789?r=68811442&page=23 let postId = ''; let replyId = ''; let inputPage = 0; // 解析 URL 中的主题号、回复号、页码 const urlMatch = raw.match(/(?:https?:\/\/[^/]*\/t\/)?(\d{6,8})(?:\?([^#]*))?(?:#.*)?$/i); const simpleMatch = raw.match(/^(?:No\.)?(\d{6,8})$/); if (urlMatch) { postId = urlMatch[1]; const qs = urlMatch[2] || ''; const rMatch = qs.match(/(?:^|&)r=(\d{6,8})(?:&|$)/); const pMatch = qs.match(/(?:^|&)page=(\d+)(?:&|$)/); replyId = rMatch ? rMatch[1] : ''; inputPage = pMatch ? parseInt(pMatch[1], 10) : 0; } else if (simpleMatch) { postId = simpleMatch[1]; } else { toast('格式错误,支持:No.67024789、67024789、https://nmbxd1.com/t/67024789、67024789?r=68811442&page=23'); return; } // 前后号相同 → 直接当主题处理 if (replyId && replyId === postId) replyId = ''; const existingStore = getPostHistoryStore(); // 查找是否已存在匹配记录:有 replyId 时只匹配回复号,无 replyId 时匹配主题号 const existingMatch = replyId ? Object.values(existingStore.items).find(item => String(item.id) === replyId || String(item.postId) === replyId ) : Object.values(existingStore.items).find(item => String(item.id) === postId || String(item.postId) === postId ); // 已存在且有完整信息 → 检查是否需要补充页码 if (existingMatch && existingMatch.resto && existingMatch.resto !== '0') { if (inputPage && inputPage !== Number(existingMatch.page || 0)) { // 有新页码且与已有记录不同 → 更新页码 updatePostHistoryRecord(existingMatch.localId, { page: inputPage }); renderPostHistoryModule(); $('#sp_posts_manual_add_input').val(''); toast('已更新 No.' + (replyId || postId) + ' 的页码为 ' + inputPage); } else { toast(replyId ? '回复 No.' + replyId + ' 已存在' : '主题 No.' + postId + ' 已存在'); } return; } const $btn = $(this); $btn.prop('disabled', true).text('获取中…'); // 有 replyId → 直接用 ref API 获取回复内容 // 无 replyId → 用 ref API 获取帖子内容,再用 thread API 判定类型 const refTarget = replyId || postId; const refUrl = 'https://api.nmb.best/api/ref?id=' + encodeURIComponent(refTarget); const refHeaders = getPostHistoryApiCookieHeaders(); const refDetail = { id: refTarget, source: 'manual-add', api: true }; gmRequest(refUrl, 'text', refHeaders).then(async (resp) => { const post = parsePostHistoryRefResponse(resp, refDetail); if (!post || !post.id) { toast('未找到该串/回复'); return; } let type = 'thread'; let resto = '0'; let fid = getPostHistoryPostFid(post); if (replyId) { // 有 replyId → 明确是回复,resto = 主题号 type = 'reply'; resto = postId; try { const pageData = await fetchPostHistoryThreadPage(postId, 1, { source: 'manual-add-fid' }); if (!fid) fid = getPostHistoryPostFid(pageData && pageData.thread) || ''; } catch (e) {} } else { // 无 replyId → 用 thread API 判定类型 try { const pageData = await fetchPostHistoryThreadPage(post.id, 1, { source: 'manual-add-type' }); const thread = pageData && pageData.thread; if (thread && thread.id && String(thread.id) === String(post.id)) { if (!fid) fid = getPostHistoryPostFid(thread) || ''; } else { type = 'reply'; toast('该串号为回复,无法确定所属主题'); } } catch (e) { type = 'reply'; toast('该串号为回复,无法确定所属主题'); } } // 饼干校验 const userHash = String(post.user_hash || post.userHash || '').trim(); const cookieList = getCookiesList(); const matchedCookie = userHash ? Object.values(cookieList).find(c => abbreviateName(c.name || '') === userHash) : null; if (userHash && !matchedCookie) { if (!window.confirm('No.' + (replyId || postId) + ' 对应的饼干 ' + userHash + ' 不在当前列表之中,是否确认添加?')) { $btn.prop('disabled', false).text('手动添加'); return; } } const serverContentRaw = post.content || ''; const serverContentText = normalizePostHistoryText(serverContentRaw); const imageFile = buildPostHistoryImageFile(post.img, post.ext); // 无主题号的回复 → url 留空,不构建跳转链接 const url = (type === 'reply' && resto === '0') ? '' : buildPostHistoryUrl(type, replyId || post.id, resto); if (existingMatch) { // 已存在但 resto=0 → 补充真实串号和相关信息 const patch = {}; if (type === 'reply' && resto !== '0') { patch.resto = resto; patch.threadId = resto; patch.url = url; } if (inputPage && !existingMatch.page) { patch.page = inputPage; } if (fid && !existingMatch.fid) { patch.fid = fid; patch.forumName = getPostHistoryForumNameByFid(fid); } if (imageFile && !existingMatch.imageFile) { Object.assign(patch, { imageFile, imageImg: post.img || '', imageExt: post.ext || '' }); } updatePostHistoryRecord(existingMatch.localId, patch); renderPostHistoryModule(); $('#sp_posts_manual_add_input').val(''); const updated = []; if (patch.resto) updated.push('串号'); if (patch.page) updated.push('页码'); if (patch.fid) updated.push('板块'); if (patch.imageFile) updated.push('图片'); toast('已更新 No.' + (replyId || postId) + ':' + (updated.join('、') || '无变化')); } else { // 新记录 const localId = 'local-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); const record = { status: 'confirmed', type, localId, id: post.id, resto, threadId: type === 'reply' ? resto : post.id, postId: post.id, page: inputPage || Number(post.page) || (type === 'thread' ? 1 : 0), fid, forumName: getPostHistoryForumNameByFid(fid), title: post.title || '', name: post.name || '', email: post.email || '', contentRaw: serverContentRaw, contentText: serverContentText, contentHash: hashPostHistoryText(serverContentText), contentHtml: sanitizePostHistoryServerContentHtml(serverContentRaw), userHash, submittedAt: Date.parse(post.now) || Date.now(), confirmedAt: Date.now(), sourceUrl: location.href, url }; if (imageFile) Object.assign(record, { imageFile, imageImg: post.img || '', imageExt: post.ext || '' }); upsertPostHistoryRecord(record); renderPostHistoryModule(); $('#sp_posts_manual_add_input').val(''); toast(type === 'thread' ? '已添加主题 No.' + postId : '已添加回复 No.' + (replyId || postId)); } }).catch(() => { toast('获取失败,请检查串号或网络'); }).finally(() => { $btn.prop('disabled', false).text('手动添加'); }); }); $('#sp_posts_manual_add_input').off('keydown.xdex-post-history-manual').on('keydown.xdex-post-history-manual', function (e) { if (e.key === 'Enter') { e.preventDefault(); $('#sp_posts_manual_add_btn').trigger('click.xdex-post-history-manual'); } }); } function getFavoriteThreadsConfig() { try { const cfg = Object.assign({}, SettingPanel.defaults, SettingPanel.state || {}, GM_getValue(SettingPanel.key, {})); cfg.favoriteThreads = normalizeFavoriteThreads(cfg.favoriteThreads); return cfg; } catch (e) { const cfg = Object.assign({}, SettingPanel.defaults, SettingPanel.state || {}); cfg.favoriteThreads = normalizeFavoriteThreads(cfg.favoriteThreads); return cfg; } } function isFavoriteThreadPageLocation() { const path = window.location.pathname || ''; return /^\/t\/\d{6,8}(?:\/\d+)?\/?$/.test(path) || /^\/Forum\/po\/id\/\d{6,8}(?:\/page\/\d+)?(?:\.html)?$/i.test(path); } function getCurrentFavoriteThreadId() { if (!isFavoriteThreadPageLocation()) return ''; const fromUrl = normalizeFavoriteThreadInput(window.location.href); if (fromUrl) return fromUrl; const mainThreadId = document.querySelector('.h-threads-list .h-threads-item[data-threads-id]')?.getAttribute('data-threads-id') || ''; return isValidThreadId(mainThreadId) ? mainThreadId : ''; } function getFavoriteThreadsAddLinkText() { return isFavoriteThreadPageLocation() ? '添加当前串' : '添加常用串'; } function getCurrentFavoriteThreadDesc() { const fromTitle = String(document.title || '').replace(/\s+-\s+No\.\d{6,8}.*$/, '').trim(); const title = fromTitle && !/^No\.\d{6,8}/.test(fromTitle) ? fromTitle : selectEnhanceIslandTitleText(); return trimFavoriteThreadDesc(title); } function openFavoriteThreadsSettingsPanel(options = {}) { try { if (!$('#sp_btn').length) SettingPanel.init(); $('#sp_btn').trigger('click'); window.setTimeout(() => { const $container = $('#favorite-thread-inputs-container'); const $fold = $container.closest('.sp_fold'); const $body = $fold.children('.sp_fold_body'); if (!$body.is(':visible')) { $body.slideDown(150); $('#btn_sp_favoriteThreads,#btn_group_favoriteThreads').removeClass('xdex-btn-hidden'); } if ($fold[0]) $fold[0].scrollIntoView({ block: 'center' }); if (options.addEmptyGroup) { const nextIndex = $container.find('.favorite-thread-row').length + 1; $container.append(buildFavoriteThreadRowHtml(nextIndex)); } const $target = $container.find('.favorite-thread-id-input').last(); if ($target.length) $target.focus(); }, 0); } catch (e) { console.warn('[favoriteThreads] open settings failed:', e); } } function ensureSettingsPanelOpen() { if (!$('#sp_btn').length) SettingPanel.init(); if (typeof window.__xdexSyncDarkReaderTheme === 'function') { try { window.__xdexSyncDarkReaderTheme(); } catch (e) {} } const $cover = $('#sp_cover'); if ($cover.length && !$cover.is(':visible')) $cover.fadeIn(); if (typeof spUpdate === 'function') { try { spUpdate('maybeShowPendingUpdateDialogOnPanelOpen'); } catch (e) {} } else if (typeof maybeShowPendingUpdateDialogOnPanelOpen === 'function') { try { maybeShowPendingUpdateDialogOnPanelOpen(); } catch (e) {} } } function openSettingsPanelModuleTab(moduleName) { ensureSettingsPanelOpen(); // 委托在 #sp_panel_tab_slot 上,需 bubble 的 trigger('click');init 已不再预渲染历史,故只会构建一次 const $tab = $('#sp_panel_tab_slot [data-sp-module="' + moduleName + '"]'); if ($tab.length) $tab.trigger('click'); return $tab.length > 0; } function openThreadHistorySettingsPanel() { try { if (!openSettingsPanelModuleTab('history')) renderThreadHistoryModuleSoon(); } catch (e) { console.warn('[thread-history] open settings failed:', e); } } function openPostHistorySettingsPanel() { try { if (!openSettingsPanelModuleTab('posts')) renderPostHistoryModuleSoon(); } catch (e) { console.warn('[post-history] open settings failed:', e); } } function openSubscriptionFeedSettingsPanel() { try { if (!openSettingsPanelModuleTab('feeds') && typeof renderSubscriptionFeedModule === 'function') { renderSubscriptionFeedModule(); } } catch (e) { console.warn('[subscription-feed] open settings failed:', e); } } function setXDexSidebarExLabel(link, label) { link.classList.add('xdex-sidebar-ex-link'); link.textContent = ''; link.appendChild(document.createTextNode(label)); const badge = document.createElement('sub'); badge.className = 'xdex-sidebar-ex-badge'; badge.style.cssText = 'color: darkorange; font-weight: bold;'; badge.setAttribute('aria-hidden', 'true'); badge.textContent = 'EX'; link.appendChild(badge); } function createPostHistoryMenuNode() { const li = document.createElement('li'); li.id = 'xdex-post-history-menu'; const link = document.createElement('a'); link.href = '#'; link.className = 'h-nav-parent-header fr-bold-33d0c43d3b0'; link.setAttribute('achecked', '1'); setXDexSidebarExLabel(link, '我的发言'); link.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openPostHistorySettingsPanel(); return false; }; li.appendChild(link); return li; } function createSubscriptionFeedMenuNode() { const li = document.createElement('li'); li.id = 'xdex-subscription-feed-menu'; const link = document.createElement('a'); link.href = '#'; link.className = 'h-nav-parent-header fr-bold-33d0c43d3b0'; link.setAttribute('achecked', '1'); setXDexSidebarExLabel(link, '我的订阅'); link.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openSubscriptionFeedSettingsPanel(); return false; }; li.appendChild(link); return li; } function addCurrentThreadToFavoriteThreads() { const threadId = getCurrentFavoriteThreadId(); if (!threadId) { openFavoriteThreadsSettingsPanel({ addEmptyGroup: true }); toast('请在常用串设置中手动填写'); return; } const cfg = getFavoriteThreadsConfig(); const duplicateIndex = cfg.favoriteThreads.findIndex((item) => item.threadId === threadId); if (duplicateIndex >= 0) { const dup = cfg.favoriteThreads[duplicateIndex]; const suffix = dup.desc ? `(${dup.desc})` : ''; toast(`当前串已在第${duplicateIndex + 1}组${suffix}`); return; } const settings = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {}), SettingPanel.state || {}); settings.favoriteThreads = normalizeFavoriteThreads([...(cfg.favoriteThreads || []), { desc: getCurrentFavoriteThreadDesc(), threadId, }]); SettingPanel.state = settings; GM_setValue(SettingPanel.key, settings); try { SettingPanel.syncInputs(); } catch (e) {} renderFavoriteThreadsMenu(); toast('已添加当前串到常用串'); } function ensureFavoriteThreadsMenuStyle() { if (document.getElementById('xdex-favorite-threads-menu-style')) return; const style = document.createElement('style'); style.id = 'xdex-favorite-threads-menu-style'; style.textContent = ` #h-menu-content .xdex-sidebar-ex-badge { color: darkorange; font-weight: bold; font-size: 10px; line-height: 1; margin-left: 1px; } #h-menu-content.uk-nav-parent-icon > #xdex-favorite-threads-menu > a::after { content: "\f104"; width: 20px; margin-right: -10px; float: right; font-family: FontAwesome; text-align: center; } #h-menu-content.uk-nav-parent-icon > #xdex-favorite-threads-menu.uk-open > a::after { content: "\f107"; } #xdex-favorite-threads-menu .xdex-favorite-add-current { text-decoration: underline; } `; document.head.appendChild(style); } function createFavoriteThreadsMenuNode(items, isOpen = false) { const li = document.createElement('li'); li.id = 'xdex-favorite-threads-menu'; li.className = 'uk-parent'; if (isOpen) li.classList.add('uk-open'); const header = document.createElement('a'); header.href = '#'; header.className = 'h-nav-parent-header fr-bold-33d0c43d3b0'; header.setAttribute('achecked', '1'); header.setAttribute('onclick', 'return false;'); setXDexSidebarExLabel(header, '常用串'); li.appendChild(header); const wrapper = document.createElement('div'); wrapper.style.cssText = `overflow:hidden;height:${isOpen ? 'auto' : '0'};position:relative;`; wrapper.classList.toggle('uk-hidden', !isOpen); const list = document.createElement('ul'); list.className = 'uk-nav-sub'; const addItem = document.createElement('li'); const addLink = document.createElement('a'); addLink.href = '#'; addLink.className = 'xdex-favorite-add-current'; addLink.setAttribute('achecked', '1'); addLink.textContent = getFavoriteThreadsAddLinkText(); addLink.onclick = (e) => { e.preventDefault(); e.stopPropagation(); addCurrentThreadToFavoriteThreads(); return false; }; addItem.appendChild(addLink); list.appendChild(addItem); items.forEach((item) => { const subItem = document.createElement('li'); const link = document.createElement('a'); link.href = getLatestThreadHistoryUrl(item.threadId) || makeFavoriteThreadUrl(item.threadId); link.dataset.threadId = item.threadId; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.setAttribute('achecked', '1'); link.textContent = formatFavoriteThreadMenuText(item); link.title = item.desc ? `${item.desc} - ${item.threadId}` : ''; subItem.appendChild(link); list.appendChild(subItem); }); wrapper.appendChild(list); li.appendChild(wrapper); if (window.jQuery) $(li).data('list-container', $(wrapper)); li.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); header.addEventListener('click', (e) => { e.preventDefault(); e.stopImmediatePropagation(); const opening = !li.classList.contains('uk-open'); li.classList.toggle('uk-open', opening); li.setAttribute('aria-expanded', opening ? 'true' : 'false'); if (window.jQuery) { if (opening) wrapper.classList.remove('uk-hidden'); $(wrapper).stop(true, true).animate({ height: opening ? $(list).outerHeight() : 0 }, () => { if (opening && li.classList.contains('uk-open')) { wrapper.style.height = 'auto'; wrapper.classList.remove('uk-hidden'); } else if (!opening) { wrapper.style.height = '0'; wrapper.classList.add('uk-hidden'); } }); return; } if (opening) { wrapper.classList.remove('uk-hidden'); wrapper.style.height = `${list.scrollHeight}px`; window.setTimeout(() => { if (li.classList.contains('uk-open')) wrapper.style.height = 'auto'; }, 160); } else { wrapper.style.height = `${list.scrollHeight}px`; window.requestAnimationFrame(() => { wrapper.style.height = '0'; wrapper.classList.add('uk-hidden'); }); } }); return li; } function createThreadHistoryMenuNode() { const li = document.createElement('li'); li.id = 'xdex-thread-history-menu'; const link = document.createElement('a'); link.href = '#'; link.className = 'h-nav-parent-header fr-bold-33d0c43d3b0'; link.setAttribute('achecked', '1'); setXDexSidebarExLabel(link, '浏览历史'); link.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openThreadHistorySettingsPanel(); return false; }; li.appendChild(link); return li; } function renderFavoriteThreadsMenu() { const menu = document.getElementById('h-menu-content') || document.querySelector('.h-menu-content'); if (!menu) return; menu.classList.add('uk-nav-parent-icon'); ensureFavoriteThreadsMenuStyle(); const old = document.getElementById('xdex-favorite-threads-menu'); const oldThreadHistory = document.getElementById('xdex-thread-history-menu'); const oldPostHistory = document.getElementById('xdex-post-history-menu'); const oldSubscriptionFeed = document.getElementById('xdex-subscription-feed-menu'); const wasOpen = !!(old && old.classList.contains('uk-open')); if (old) old.remove(); if (oldThreadHistory) oldThreadHistory.remove(); if (oldPostHistory) oldPostHistory.remove(); if (oldSubscriptionFeed) oldSubscriptionFeed.remove(); const items = getFavoriteThreadsConfig().favoriteThreads || []; const node = createFavoriteThreadsMenuNode(items, wasOpen); // 历史开关关闭时对应入口不出现(已有数据保留,仅隐藏入口) const threadHistoryNode = isThreadHistoryEnabledNow() ? createThreadHistoryMenuNode() : null; const postHistoryNode = isPostHistoryEnabledNow() ? createPostHistoryMenuNode() : null; const subscriptionFeedNode = createSubscriptionFeedMenuNode(); const timeline = Array.from(menu.children).find((li) => { const header = li && li.querySelector ? li.querySelector(':scope > .h-nav-parent-header, :scope > a') : null; return header && (header.textContent || '').trim() === '时间线'; }); menu.insertBefore(node, timeline || menu.firstChild); let insertAfter = node; if (threadHistoryNode) { menu.insertBefore(threadHistoryNode, timeline || insertAfter.nextSibling); insertAfter = threadHistoryNode; } if (postHistoryNode) { menu.insertBefore(postHistoryNode, timeline || insertAfter.nextSibling); insertAfter = postHistoryNode; } menu.insertBefore(subscriptionFeedNode, timeline || insertAfter.nextSibling); } // 同步常用串菜单与设置面板串内链接的 href(浏览历史更新后调用,指向最近查看页,允许低于最远访问页) function syncFavoriteThreadsLinks() { const links = document.querySelectorAll('#xdex-favorite-threads-menu a[data-thread-id], #sp_panel_footer a[data-thread-id]'); if (!links.length) return; const store = getThreadHistoryStore(); links.forEach(link => { const tid = link.dataset.threadId; if (tid) { // 优先普通模式,没有再回退只看 PO;都没有则指向普通第 1 页 const normal = store.items[getThreadHistoryKey('normal', tid)]; const item = normal || store.items[getThreadHistoryKey('po', tid)]; if (item) { const page = item.page || 1; link.href = buildThreadHistoryPageUrl(item.mode, tid, page); } else { link.href = buildThreadHistoryPageUrl('normal', tid, 1); } } }); } // ─── 页面内「订阅EX」快捷按钮 ───────────────────────────────────────────────────────── function ensureSubscriptionExButtonStyle() { if (document.getElementById('xdex-subscription-ex-btn-style')) return; const style = document.createElement('style'); style.id = 'xdex-subscription-ex-btn-style'; style.textContent = ` .xdex-sub-ex-btn { color: #00FFCC; font-weight: normal; } .xdex-sub-ex-btn .xdex-sidebar-ex-badge { color: #66CCFF; font-weight: bold; font-size: 10px; line-height: 1; margin-left: 1px; } .xdex-sub-ex-btn:hover { text-decoration: underline; } /* 深色模式:信息行链接提亮规则(!important)会盖掉品牌色,这里以同特异性+后源顺序夺回 */ :root.xdex-custom-dark .h-threads-item .h-threads-info a.xdex-sub-ex-btn { color: #00FFCC !important; } :root.xdex-custom-dark .h-threads-item .h-threads-info a.xdex-sub-ex-btn .xdex-sidebar-ex-badge { color: #66CCFF !important; } .xdex-feed-selector-display { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border: 1px solid var(--xdex-sp-border, #bfa58f); border-radius: 8px; background: var(--xdex-sp-panel-bg, #FFFFEE); cursor: pointer; font-size: 13px; line-height: 1.4; min-height: 1.4em; user-select: none; overflow: hidden; } .xdex-feed-display-desc { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xdex-feed-display-uuid { font-size: 12px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; filter: blur(4px); transition: filter 0.15s; } /* 无备注时仅显示订阅号:模糊稍浅,能感知有内容被打码,但看不清具体字符 */ .xdex-feed-display-uuid.xdex-feed-uuid-light-blur { filter: blur(2.5px); opacity: 0.85; } .xdex-feed-display-uuid:empty { display: none; } .xdex-feed-chevron { flex: 0 0 auto; margin-left: auto; color: #C61200; transition: transform .15s ease; transform: rotate(0deg); } .xdex-feed-selector-display[aria-expanded="true"] .xdex-feed-chevron { transform: rotate(-90deg); } .xdex-feed-selector-dropdown { position: absolute; top: 100%; left: 0; right: 0; margin-top: 2px; border: 1px solid var(--xdex-sp-border, #bfa58f); border-radius: 8px; background: var(--xdex-sp-panel-bg, #FFFFEE); box-shadow: 0 2px 8px var(--xdex-sp-shadow, rgba(0,0,0,.15)); z-index: 100; max-height: 200px; overflow-y: auto; } .xdex-feed-option { padding: 6px 8px; cursor: pointer; font-size: 13px; /* --foreground 未定义会恒取 #333,深色下与 #2b2c2d 底同暗不可见;改为继承面板前景(与选择框本体一致) */ color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xdex-feed-option:hover { background: #F0E0D6; } .xdex-feed-option.active { background: #F0E0D6; font-weight: bold; } :root.xdex-darkreader-active .xdex-feed-option:hover { background: #3d3530; } :root.xdex-darkreader-active .xdex-feed-option.active { background: #483327; } `; document.head.appendChild(style); } function resolveSubscriptionFeedUuid() { const panelSelectedUuid = String($('#sp_feeds_selector').val() || '').trim(); if (panelSelectedUuid) return panelSelectedUuid; const dropdownSelectedUuid = String($('#sp_feeds_selector_dropdown .xdex-feed-option.active').data('uuid') || '').trim(); if (dropdownSelectedUuid) return dropdownSelectedUuid; const uuid = subscriptionFeedCurrentUuid || getActiveSubscriptionFeedUuid() || ''; if (uuid) { const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; if (feeds.some((f) => String(f.uuid || '').trim() === uuid)) return uuid; } const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; return feeds.length ? feeds[0].uuid : ''; } // 订阅到指定订阅号(默认值直订 / 弹窗选择共用) function subscribeThreadToFeed(tid, uuid) { const targetTid = String(tid || '').trim(); const targetUuid = String(uuid || '').trim(); if (!targetTid || !targetUuid) return; const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; const feed = feeds.find((f) => String((f && f.uuid) || '').trim() === targetUuid); const label = (feed && feed.desc) ? feed.desc : ((feed && feed.uuid) || targetUuid); GM_xmlhttpRequest({ method: 'POST', url: `${SUBSCRIPTION_FEED_API_BASE}/addFeed`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `uuid=${encodeURIComponent(targetUuid)}&tid=${encodeURIComponent(targetTid)}`, onload: (resp) => { if (resp.status >= 200 && resp.status < 300) { invalidateSubscriptionFeedSession(targetUuid, 'add-feed'); toast(`已订阅 No.${targetTid} 到「${label}」`); } else { toast(`订阅失败 (${resp.status})`); } }, onerror: () => toast('订阅失败,网络错误') }); } // 选择订阅号弹窗:未设置默认订阅号时,点击“订阅EX”每次弹出。 // 结构对齐 showCookieConfirmDialog:标题 + 串号(接入引用格式)+ 列表(每行尾部带“默认”开关与默认金标) // + 键盘上下选择/Enter 确认 + 下限高度、列表滚动;无用量统计等订阅场景不需要的元素。 function showSubscriptionFeedPicker(tid) { const targetTid = String(tid || '').trim(); if (!targetTid) return; if (document.getElementById('subscription-feed-picker-modal')) return; const feeds = ((typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []) .filter((f) => f && String(f.uuid || '').trim()); if (!feeds.length) { toast('请先在设置中添加一个订阅号'); return; } let selectedUuid = String(feeds[0].uuid || '').trim(); let focusedIndex = 0; const $m = $(` <div id="subscription-feed-picker-modal" tabindex="-1" style="position:fixed;inset:0;z-index:10002;display:flex;align-items:center;justify-content:center;outline:none;"> <div class="subscription-feed-picker-backdrop" style="position:absolute;inset:0;background:rgba(0,0,0,.4);"></div> <div class="subscription-feed-picker-dialog" role="dialog" aria-modal="true" aria-labelledby="subscription-feed-picker-title" style="position:relative;display:flex;flex-direction:column;width:420px;max-width:calc(100vw - 32px);min-height:min(280px, calc(100vh - 32px));max-height:min(80vh, calc(100vh - 32px));box-sizing:border-box;background:var(--xdex-sp-panel-bg, #FFFFEE);border:1px solid var(--xdex-sp-border, #ccc);border-radius:8px;box-shadow:0 2px 12px var(--xdex-sp-shadow, rgba(0,0,0,.25));padding:16px;overflow:hidden;"> <h3 id="subscription-feed-picker-title" style="margin:0 0 8px;font-size:16px;color:var(--foreground, #333);flex:0 0 auto;">选择订阅号</h3> <p style="margin:0 0 10px;display:flex;justify-content:space-between;align-items:center;gap:8px;color:var(--muted-foreground, #666);font-size:13px;flex:0 0 auto;"> <span>订阅 <span id="subscription-feed-picker-tid"></span> 到:</span> <span id="subscription-feed-picker-default-label" style="color:#ffb300;font-size:11px;white-space:nowrap;"></span> </p> <div id="subscription-feed-picker-list" style="flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;padding-right:2px;"></div> <div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--xdex-sp-border, #ddd);font-size:12px;line-height:1.45;color:var(--muted-foreground, #888);flex:0 0 auto;">设为默认后,之后点击“订阅EX”将直接订阅到默认订阅号,不再弹窗二次确认;如需按分类分别订阅,建议保持关闭。</div> <div id="subscription-feed-picker-actions" style="display:flex;align-items:center;gap:4px;margin-top:10px;flex:0 0 auto;"> <button id="subscription-feed-picker-ok" type="button" style="flex:1;padding:6px 10px;">订阅</button> <button id="subscription-feed-picker-cancel" type="button" style="padding:6px 16px;flex-shrink:0;">取消</button> </div> </div> </div>`); const $list = $m.find('#subscription-feed-picker-list'); const feedLabel = (feed) => (feed && feed.desc) ? feed.desc : ((feed && feed.uuid) || ''); // 主按钮文案:选中项 + 是否为默认(与饼干二次确认一致) const updateOk = () => { const feed = feeds.find((f) => String(f.uuid || '').trim() === selectedUuid); const isDefault = getDefaultSubscriptionFeedUuid() === selectedUuid; $m.find('#subscription-feed-picker-ok').text(`订阅到「${feedLabel(feed) || selectedUuid}」` + (isDefault ? '(默认)' : '')); }; // 右上金色「默认: xxx」文本 + 各行的默认金标/开关选中态 const refreshDefaultVisuals = () => { const cur = getDefaultSubscriptionFeedUuid(); const $lab = $m.find('#subscription-feed-picker-default-label'); const curFeed = feeds.find((f) => String(f.uuid || '').trim() === cur); if (curFeed) $lab.text('默认: ' + feedLabel(curFeed)); else $lab.text(''); $list.find('.subscription-feed-picker-item').each(function () { const uuid = String($(this).attr('data-uuid') || ''); const isDef = uuid === cur; $(this).find('.subscription-feed-picker-default').prop('checked', isDef); $(this).find('.xdex-feed-picker-default-badge').toggle(!!isDef); }); updateOk(); }; feeds.forEach((feed) => { const uuid = String(feed.uuid || '').trim(); const desc = String(feed.desc || '').trim(); // 复用面板下拉的 .xdex-feed-option(hover 底色),用内联样式覆盖为行布局 const $item = $('<div class="xdex-feed-option subscription-feed-picker-item" role="option"></div>') .attr('data-uuid', uuid) .css({ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 10px', border: '1px solid var(--xdex-sp-border, #ddd)', borderRadius: '6px', marginBottom: '6px', cursor: 'pointer', whiteSpace: 'normal', overflow: 'visible', textOverflow: 'clip' }); const $text = $('<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;"></div>'); // 备注优先;无备注时直接显示订阅号 const $main = $('<div style="font-size:13px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></div>'); $main.append($('<span></span>').text(desc || uuid)); $main.append($('<span class="xdex-feed-picker-default-badge" style="display:none;margin-left:4px;background:rgba(255,193,7,.18);color:#ffb300;padding:1px 5px;border-radius:3px;font-size:11px;">默认</span>')); $text.append($main); if (desc) { $text.append($('<div style="font-size:12px;color:var(--muted-foreground, #888);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></div>').text(uuid)); } $item.append($text); // 「默认」开关置于订阅号后方(与饼干二次确认的 pin 开关同位) const $sw = $('<input type="checkbox" class="xdex-switch subscription-feed-picker-default" role="switch">') .attr('title', '设为本机默认订阅号:之后点击“订阅EX”将直接订阅到该订阅号,不再询问'); const $swLabel = $('<label style="font-size:12px;color:#6f5d50;user-select:none;">默认</label>'); $item.append($('<div style="display:flex;align-items:center;gap:6px;flex:0 0 auto;white-space:nowrap;"></div>').append($sw).append($swLabel)); $item.on('click', (e) => { // 点在开关上不触发选中 if ($(e.target).closest('.subscription-feed-picker-default').length) return; selectItem($item); }); // 立即写入默认(与设置面板行内开关、跨页同步同源),无需等确认 $sw.on('click', (e) => e.stopPropagation()); $sw.on('change', () => { const on = $sw.is(':checked'); if (on) { $list.find('.subscription-feed-picker-default').prop('checked', false); $sw.prop('checked', true); setDefaultSubscriptionFeedUuid(uuid); } else if (getDefaultSubscriptionFeedUuid() === uuid) { setDefaultSubscriptionFeedUuid(''); } refreshDefaultVisuals(); }); $list.append($item); }); const $items = () => $list.find('.subscription-feed-picker-item'); function selectItem($item) { const idx = $items().toArray().indexOf($item[0]); if (idx < 0) return; focusedIndex = idx; selectedUuid = String(feeds[idx].uuid || '').trim(); $items().css({ background: '', borderColor: 'var(--xdex-sp-border, #ddd)' }); $item.css({ background: 'rgba(0,184,148,.12)', borderColor: '#00b894' }); updateOk(); } // 键盘上下移动焦点(同步选中态与按钮文案),Enter 确认 const updateFocus = (newIndex) => { const $all = $items(); if (!$all.length) return; focusedIndex = Math.max(0, Math.min(newIndex, $all.length - 1)); $all.css('box-shadow', ''); $all.eq(focusedIndex).css('box-shadow', 'inset 0 0 0 2px rgba(102,204,255,.6)').get(0)?.scrollIntoView({ block: 'nearest' }); selectItem($all.eq(focusedIndex)); }; $('body').append($m); // 串号接入引用体系:悬停原生引用浮窗 / 点击拓展引用浮窗(与饼干二次确认同路径) $m.find('#subscription-feed-picker-tid').html('<font color="#789922">No.' + targetTid + '</font>'); try { if (typeof initExtendedContent === 'function') initExtendedContent($m[0]); } catch (e) {} // 初始:选中第一项、刷新默认金标/开关/文案 $items().first().trigger('click'); refreshDefaultVisuals(); const closePicker = () => { $m.remove(); }; $m.find('#subscription-feed-picker-ok').on('click', () => { closePicker(); subscribeThreadToFeed(targetTid, selectedUuid); }); $m.find('#subscription-feed-picker-cancel').on('click', closePicker); $m.find('.subscription-feed-picker-backdrop').on('click', closePicker); $m.on('keydown', (e) => { // 焦点在按钮上时交给原生行为(Enter 触发该按钮),避免把「取消」误当成确认 if (e.key === 'Enter' && $(e.target).is('button')) return; if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); updateFocus(focusedIndex + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); updateFocus(focusedIndex - 1); } else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); e.stopPropagation(); $m.find('#subscription-feed-picker-ok').trigger('click'); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePicker(); } }); $m[0].focus(); } function injectSubscriptionExButton(root) { ensureSubscriptionExButtonStyle(); const scope = root || document; const containers = scope.querySelectorAll ? scope.querySelectorAll('.h-threads-item[data-threads-id] > .h-threads-item-main') : []; containers.forEach((container) => { // 找到 No.xxx 链接作为插入锚点(板块页和串内页都有) const anchor = container.querySelector('a.h-threads-info-id'); if (!anchor || anchor.__xdexSubExBound) return; anchor.__xdexSubExBound = true; const sep = document.createTextNode(' '); const span = document.createElement('span'); span.className = 'h-threads-info-report-btn'; const a = document.createElement('a'); a.href = '#'; a.title = '订阅到订阅号(未设默认时弹窗选择)'; a.className = 'xdex-sub-ex-btn'; a.appendChild(document.createTextNode('订阅')); const badge = document.createElement('sub'); badge.className = 'xdex-sidebar-ex-badge'; badge.style.cssText = 'color: #66CCFF; font-weight: bold;'; badge.setAttribute('aria-hidden', 'true'); badge.textContent = 'EX'; a.appendChild(badge); a.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const tid = String(container.getAttribute('data-threads-id') || anchor.closest('.h-threads-item')?.getAttribute('data-threads-id') || '').trim(); if (!tid) return; const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; if (!feeds.length) { toast('请先在设置中添加一个订阅号'); return; } // 存在有效默认订阅号 → 直接订阅(不再弹窗确认);否则每次点击都弹窗选择订阅号 const defaultUuid = getDefaultSubscriptionFeedUuid(); if (defaultUuid) { subscribeThreadToFeed(tid, defaultUuid); return; } showSubscriptionFeedPicker(tid); }); span.appendChild(document.createTextNode('[')); span.appendChild(a); span.appendChild(document.createTextNode(']')); anchor.before(span, sep); }); } /* -------------------------------------------------- * tag 25. 发送前饼干二次确认 * -------------------------------------------------- */ function getThreadCookieStats(threadId) { if (!threadId) return {}; const store = getPostHistoryStore(); const stats = {}; let lastUsedHash = ''; let lastUsedTs = 0; store.order.forEach(key => { const item = store.items[key]; if (!item) return; const hash = String(item.userHash || '').trim(); if (!hash) return; const itemThreadId = String(item.threadId || (item.type === 'reply' ? item.resto : item.id) || ''); if (itemThreadId !== threadId) return; if (!stats[hash]) stats[hash] = { count: 0, lastUsed: 0 }; stats[hash].count++; const ts = Number(item.confirmedAt || item.submittedAt) || 0; if (ts > stats[hash].lastUsed) stats[hash].lastUsed = ts; if (ts > lastUsedTs) { lastUsedTs = ts; lastUsedHash = hash; } }); const cookieList = getCookiesList(); Object.keys(stats).forEach(hash => { const cookie = cookieList[hash]; stats[hash].name = cookie ? abbreviateName(cookie.name || '').replace(/ - 0000-00-00 00:00:00$/g, '').trim() : hash; stats[hash].desc = cookie ? (cookie.desc || '') : ''; stats[hash].isLastUsed = (hash === lastUsedHash && lastUsedTs > 0); }); return stats; } function showCookieConfirmDialog(threadId, onConfirm, onCancel, opts) { opts = opts || {}; const mode = opts.mode || 'send'; const preselectHash = opts.preselectHash || ''; const stats = getThreadCookieStats(threadId); const cookieList = getCookiesList(); const currentCookie = getCurrentCookie(); const currentId = currentCookie ? String(currentCookie.id) : ''; // 与饼干下拉列表排序一致(Object.keys 顺序) const allCookies = Object.keys(cookieList).map(id => cookieList[id]); const pref = getThreadCookiePref(threadId); let selectedId = currentId; if (preselectHash) { const match = allCookies.find(c => abbreviateName(c.name) === preselectHash); if (match) selectedId = String(match.id); } let pinnedHash = null; const $m = $( '<div id="cookie-confirm-modal" tabindex="-1" style="position:fixed;inset:0;z-index:10002;display:flex;align-items:center;justify-content:center;outline:none;">' + '<div class="cookie-confirm-backdrop" style="position:absolute;inset:0;background:rgba(0,0,0,.4);"></div>' + '<div class="cookie-confirm-dialog" style="position:relative;display:flex;flex-direction:column;width:360px;max-width:calc(100vw - 32px);max-height:min(80vh, calc(100vh - 32px));background:var(--xdex-sp-panel-bg, #FFFFEE);border:1px solid var(--xdex-sp-border, #ccc);border-radius:8px;box-shadow:0 2px 12px var(--xdex-sp-shadow, rgba(0,0,0,.25));padding:16px;overflow:hidden;">' + '<h3 style="margin:0 0 8px;font-size:16px;color:var(--foreground, #333);flex:0 0 auto;">' + (mode === 'setDefault' ? '设置默认饼干' : '确认饼干') + '</h3>' + '<p style="margin:0 0 10px;display:flex;justify-content:space-between;align-items:center;color:var(--muted-foreground, #666);font-size:13px;flex:0 0 auto;"><span>当前串 <font color="#789922">No.' + threadId + '</font></span>' + (pref ? '<span style="color:#ffb300;font-size:11px;">默认: ' + (abbreviateName((findCookieByHash(cookieList, pref.hash) || {}).name || pref.hash || '').replace(/ - 0000-00-00 00:00:00$/g, '').trim()) + '</span>' : '') + '</p>' + '<div id="cookie-confirm-scroll" style="flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;padding-right:2px;">' + '<div id="cookie-confirm-list"></div>' + '</div>' + '<div id="cookie-confirm-tail-spacer" style="flex:0 0 auto;height:48px;flex-shrink:0;"></div>' + '<div id="cookie-confirm-await-marker" aria-hidden="true" style="flex:0 0 0;border-top:1px solid var(--xdex-sp-border, #ddd);margin:0;flex-shrink:0;"></div>' + '<div id="cookie-confirm-actions" style="display:flex;align-items:center;gap:4px;flex:0 0 auto;flex-shrink:0;">' + '<button id="cookie-confirm-ok" style="flex:1;padding:6px 10px;">使用当前饼干发送</button>' + '<button id="cookie-confirm-cancel" style="padding:6px 16px;flex-shrink:0;">取消</button>' + '</div>' + '</div>' + '</div>' ); function updateOkButton() { const sel = allCookies.find(c => String(c.id) === selectedId); const selHash = sel ? abbreviateName(sel.name) : ''; const selName = sel ? abbreviateName(sel.name || '').replace(/ - 0000-00-00 00:00:00$/g, '').trim() : ''; const prefNow = getThreadCookiePref(threadId); const defaultHash = prefNow ? prefNow.hash : ''; const isSelDefault = defaultHash && selHash === defaultHash; if (mode === 'setDefault') { $m.find('#cookie-confirm-ok').text('将' + selName + '设为默认'); } else if (selectedId === currentId) { $m.find('#cookie-confirm-ok').text('使用当前饼干发送' + (isSelDefault ? '(默认)' : '')); } else { $m.find('#cookie-confirm-ok').text('切换到 ' + selName + ' 并发送' + (isSelDefault ? '(默认)' : '')); } } const $list = $m.find('#cookie-confirm-list'); allCookies.forEach(cookie => { const id = String(cookie.id); const cookieHash = abbreviateName(cookie.name || ''); const st = stats[cookieHash]; const count = st ? st.count : 0; const isCurrent = id === currentId; const isLastUsed = st && st.isLastUsed; const rawName = abbreviateName(cookie.name || '').replace(/ - 0000-00-00 00:00:00$/g, '').trim(); const rawDesc = (cookie.desc || '').trim(); const hasDesc = rawDesc && !/^0{4}-0{2}-0{2}/.test(rawDesc); const displayName = rawName + (hasDesc ? ' - ' + rawDesc : ''); const isDefault = (pref && pref.hash === cookieHash); let statsHtml = ''; if (isDefault) { statsHtml = '<span style="background:rgba(255,193,7,.18);color:#ffb300;padding:1px 5px;border-radius:3px;font-size:11px;margin-right:4px;">默认</span>'; } if (isLastUsed && count > 0) { statsHtml += '<span style="background:rgba(168,46,96,.18);color:#A82E60;padding:1px 5px;border-radius:3px;font-size:11px;">上次' + count + '</span>'; } else if (count > 0) { statsHtml += '<span style="background:rgba(102,204,255,.15);color:#66CCFF;padding:1px 5px;border-radius:3px;font-size:11px;">用过' + count + '</span>'; } else if (!isDefault) { statsHtml += '<span style="color:var(--muted-foreground, #888);font-size:12px;">无记录</span>'; } const selectedBg = isCurrent ? 'background:rgba(0,184,148,.12);border-color:#00b894;' : ''; const $item = $( '<div style="display:flex;align-items:center;gap:4px;">' + '<div data-cookie-id="' + id + '" data-cookie-hash="' + cookieHash + '" style="flex:1;display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border:1px solid var(--xdex-sp-border, #ddd);border-radius:6px;cursor:pointer;color:var(--foreground, #333);' + selectedBg + '">' + '<span class="h-threads-info-uid" data-xdex-cookie-id="' + cookieHash + '">' + displayName + '</span>' + '<span>' + statsHtml + '</span>' + '</div>' + '<input type="checkbox" class="xdex-switch xdex-pin-btn" data-pin-hash="' + cookieHash + '" role="switch" title="设为本串默认"' + (isDefault ? ' checked' : '') + '>' + '</div>' ); $item.on('click', (e) => { if ($(e.target).hasClass('xdex-pin-btn')) return; $list.find('[data-cookie-id]').css({ background: '', borderColor: 'var(--xdex-sp-border, #ddd)' }); $item.find('[data-cookie-id]').css({ background: 'rgba(0,184,148,.12)', borderColor: '#00b894' }); selectedId = id; // 双重校验:DOM 引用 + data-cookie-id 属性,任一命中即可(防 eq(-1) 选到最后一个) const $allClick = $list.find('[data-cookie-id]'); const innerEl = $item.find('[data-cookie-id]')[0]; focusedIndex = $allClick.toArray().findIndex(el => el === innerEl); if (focusedIndex < 0) { focusedIndex = $allClick.toArray().findIndex(el => $(el).attr('data-cookie-id') === String(id)); } updateOkButton(); }); // switch 点击设为默认饼干(不发送、不关闭弹窗) $item.find('.xdex-pin-btn').on('click', (e) => { e.stopPropagation(); // e.target.checked 是点击后的状态,取反得到点击前的状态 const isChecked = e.target.checked; if (isChecked) { // 点击后是打开状态 → 刚刚从关闭切换到打开:设置偏好(互斥) $list.find('.xdex-pin-btn').prop('checked', false); $item.find('.xdex-pin-btn').prop('checked', true); setThreadCookiePref(threadId, cookieHash); // 更新右上角默认提示 const $existing = $m.find('p span[style*="color:#ffb300"], p span[style*="color: rgb(255, 179, 0)"]'); if ($existing.length) { $existing.text('默认: ' + rawName); } else { $m.find('p').append('<span style="color:#ffb300;font-size:11px;">默认: ' + rawName + '</span>'); } toast('已将 ' + rawName + ' 设为本串默认饼干'); } else { // 点击后是关闭状态 → 刚刚从打开切换到关闭:清除偏好 $item.find('.xdex-pin-btn').prop('checked', false); removeThreadCookiePref(threadId); // 移除右上角默认提示 $m.find('p span[style*="color:#ffb300"], p span[style*="color: rgb(255, 179, 0)"]').remove(); // 同步关闭外部的饼干偏好开关 const outerSwitch = document.querySelector('.xdex-cookie-check-sw'); if (outerSwitch) { outerSwitch.checked = false; outerSwitch.dispatchEvent(new Event('change')); } toast('已清除当前串的偏好饼干'); } // 手动设置或清除默认饼干后,同步外部开关状态与圆圈着色 if (typeof refreshCookiePrefSwitchState === 'function') refreshCookiePrefSwitchState(); updateOkButton(); }); $list.append($item); }); // preselectHash 选中视觉 if (preselectHash) { const $pre = $list.find('[data-cookie-hash="' + preselectHash + '"]'); if ($pre.length) { $list.find('[data-cookie-id]').css({ background: '', borderColor: 'var(--xdex-sp-border, #ddd)' }); $pre.css({ background: 'rgba(0,184,148,.12)', borderColor: '#00b894' }); } } updateOkButton(); $m.find('#cookie-confirm-ok').on('click', () => { const targetCookie = cookieList[selectedId]; if (!targetCookie) { toast('饼干信息无效'); return; } const selectedHash = abbreviateName(targetCookie.name); if (mode === 'setDefault') { $m.remove(); onConfirm(selectedHash, selectedHash); return; } if (selectedId === currentId) { $m.remove(); onConfirm(selectedHash, pinnedHash); } else { switch_cookie(targetCookie, { silent: false, onDone: () => { $m.remove(); onConfirm(selectedHash, pinnedHash); }, onFail: () => { toast('切换失败,未发送'); $m.remove(); onCancel(); } }); } }); $m.find('#cookie-confirm-cancel').on('click', () => { $m.remove(); onCancel(); document.querySelector('textarea.h-post-form-textarea')?.focus(); }); $m.find('.cookie-confirm-backdrop').on('click', () => { $m.remove(); onCancel(); document.querySelector('textarea.h-post-form-textarea')?.focus(); }); $('body').append($m); $m[0].focus(); // 应用引用格式拓展和标记饼干 try { if (typeof initExtendedContent === "function") initExtendedContent($m[0]); } catch (e) {} try { if (typeof markAllCookies === "function") markAllCookies(getFilterConfig().markedGroups || [], $m[0]); } catch (e) {} // ── 键盘导航 ── const $items = () => $list.find('[data-cookie-id]'); let focusedIndex = allCookies.findIndex(c => String(c.id) === currentId); if (focusedIndex < 0) focusedIndex = 0; function updateFocus(newIndex) { const $all = $items(); if (!$all.length) return; focusedIndex = Math.max(0, Math.min(newIndex, $all.length - 1)); $all.css('box-shadow', ''); $all.eq(focusedIndex).css('box-shadow', 'inset 0 0 0 2px rgba(102,204,255,.6)').get(0)?.scrollIntoView({ block: 'nearest' }); // 同步选中状态和按钮文字 const focusId = $all.eq(focusedIndex).attr('data-cookie-id'); if (focusId) { selectedId = focusId; $list.find('[data-cookie-id]').css({ background: '', borderColor: 'var(--xdex-sp-border, #ddd)' }); $all.eq(focusedIndex).css({ background: 'rgba(0,184,148,.12)', borderColor: '#00b894' }); updateOkButton(); } } updateFocus(focusedIndex); // 列表本身可滚动:把聚焦项滚到视区里,避免键盘首次可见性糟糕 requestAnimationFrame(() => { const $all = $items(); const $focus = $all.eq(focusedIndex); if ($focus.length) $focus[0].scrollIntoView({ block: 'center' }); }); // ── 测量最后一项真实高度,同步给底部 spacer ── const syncTailSpacer = () => { const $last = $list.children().last(); if ($last.length) { $m.find('#cookie-confirm-tail-spacer').height($last[0].getBoundingClientRect().height); } }; // ── 视口变化 / 旋转时重新约束弹窗高度,让聚焦项可见 ── const recomputeViewport = () => { const vh = window.innerHeight || document.documentElement.clientHeight; const target = Math.max(180, Math.min(vh - 32, Math.floor(vh * 0.8))); $m.find('.cookie-confirm-dialog').css('max-height', target + 'px'); syncTailSpacer(); }; requestAnimationFrame(() => { recomputeViewport(); syncTailSpacer(); }); $(window).on('resize.xdexCookieConfirm orientationchange.xdexCookieConfirm', recomputeViewport); $m.on('remove', () => $(window).off('.xdexCookieConfirm')); $m.on('keydown', (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); updateFocus(focusedIndex + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); updateFocus(focusedIndex - 1); } else if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); // 直接用当前聚焦的饼干发送 const $all = $items(); if ($all.eq(focusedIndex).length) $all.eq(focusedIndex).trigger('click'); $m.find('#cookie-confirm-ok').trigger('click'); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); $m.remove(); onCancel(); document.querySelector('textarea.h-post-form-textarea')?.focus(); } }); } // ── 串内饼干偏好存储 ── const THREAD_COOKIE_PREFS_KEY = 'xdex_thread_cookie_prefs'; // 一次性清理旧的脏数据(调试期间写入的错误偏好) if (!GM_getValue('xdex_cookie_pref_migrated_v1', false)) { try { GM_setValue(THREAD_COOKIE_PREFS_KEY, {}); GM_setValue('xdex_cookie_pref_migrated_v1', true); } catch (e) {} } function getThreadCookiePrefs() { try { return GM_getValue(THREAD_COOKIE_PREFS_KEY, {}); } catch (e) { return {}; } } function getThreadCookiePref(threadId) { if (!threadId) return null; const pref = getThreadCookiePrefs()[threadId]; // 如果是清除标记,返回 null if (pref && pref.cleared) return null; return pref || null; } function setThreadCookiePref(threadId, hash) { if (!threadId || !hash) return; const prefs = getThreadCookiePrefs(); prefs[threadId] = { hash: String(hash), setAt: Date.now() }; try { GM_setValue(THREAD_COOKIE_PREFS_KEY, prefs); } catch (e) {} } function removeThreadCookiePref(threadId) { if (!threadId) return; const prefs = getThreadCookiePrefs(); // 存储清除标记,防止 initThreadCookiePref 自动重新设置 prefs[threadId] = { hash: '', cleared: true, clearedAt: Date.now() }; try { GM_setValue(THREAD_COOKIE_PREFS_KEY, prefs); } catch (e) {} } // 在 cookieList 中通过 hash 查找饼干(兼容 key 为 id 或 hash 的情况) function findCookieByHash(cookieList, hash) { if (!cookieList || !hash) return null; return Object.values(cookieList).find(c => abbreviateName(c.name || '') === hash) || null; } // 当前饼干是否命中本串偏好:未开启偏好返回 null,命中 true,未命中 false function isCookieMatchingThreadPref(threadId) { const pref = getThreadCookiePref(threadId); if (!pref || !pref.hash) return null; const cur = getCurrentCookie(); if (!cur) return false; return abbreviateName(cur.name || '') === String(pref.hash); } // 依据判定结果给开关圆圈加/去着色类并同步 tooltip;仅作视觉反馈,不改变任何发送逻辑 function refreshCookiePrefSwitchState() { document.querySelectorAll('.xdex-cookie-check-sw').forEach(sw => { const tid = sw.dataset ? (sw.dataset.threadId || '') : ''; const pref = tid ? getThreadCookiePref(tid) : null; const enabled = !!(pref && pref.hash); // 开关状态与齿轮可见性同步自实际偏好:未开启偏好(开关关闭)时圆圈恒为白色、无提示 if (sw.checked !== enabled) sw.checked = enabled; const area = sw.closest ? sw.closest('.xdex-cookie-check-area') : null; const editBtn = area ? area.querySelector('.xdex-edit-default-btn') : null; if (editBtn) { editBtn.style.visibility = enabled ? 'visible' : 'hidden'; editBtn.style.pointerEvents = enabled ? 'auto' : 'none'; } const matched = enabled ? isCookieMatchingThreadPref(tid) : null; sw.classList.toggle('xdex-cookie-pref-match', matched === true); sw.classList.toggle('xdex-cookie-pref-mismatch', matched === false); if (matched === true) { sw.title = '当前饼干即为默认饼干'; } else if (matched === false) { const prefHash = String((pref || {}).hash || ''); const prefCookie = findCookieByHash(getCookiesList(), prefHash); const prefName = (prefCookie ? abbreviateName(prefCookie.name || '') : prefHash) || '未设置'; sw.title = '当前饼干非默认饼干,默认饼干为' + prefName; } else { sw.removeAttribute('title'); } }); } // ── PO 主饼干检测 ── function detectPOCookieHash() { // 直接从 PO 的 uid 文本提取 hash(如 "ID:z19vISg" → "z19vISg") const uidSpan = document.querySelector('.h-threads-item-main .h-threads-info .h-threads-info-uid'); if (!uidSpan) return null; const rawText = (uidSpan.textContent || '').trim(); const poHash = rawText.replace(/^ID:\s*/, '').trim(); if (!poHash) return null; return findCookieByHash(getCookiesList(), poHash) ? poHash : null; } // ── 发言历史检测 ── function detectHistoryCookieHash(threadId) { if (!threadId) return null; const store = getPostHistoryStore(); const cookieList = getCookiesList(); for (const key of (store.order || [])) { const item = store.items[key]; if (!item) continue; if (String(item.type) !== 'thread') continue; const tid = String(item.id || item.threadId || ''); if (tid !== String(threadId)) continue; const hash = String(item.userHash || '').trim(); if (hash && findCookieByHash(cookieList, hash)) return hash; } return null; } // ── 初始化串内偏好 ── function initThreadCookiePref(threadId) { if (!threadId) return; // 直接检查原始 prefs(包括清除标记) const rawPref = getThreadCookiePrefs()[threadId]; if (rawPref) { // 如果用户主动清除了偏好,不自动设置 if (rawPref.cleared) return; if (rawPref.hash && findCookieByHash(getCookiesList(), rawPref.hash)) return; // 有效 return; // 失效留给发送时处理 } const poHash = detectPOCookieHash(); console.log('[cookie-pref] init threadId=' + threadId + ' poHash=' + poHash); if (poHash) { setThreadCookiePref(threadId, poHash); refreshCookiePrefSwitchState(); return; } const histHash = detectHistoryCookieHash(threadId); console.log('[cookie-pref] init threadId=' + threadId + ' histHash=' + histHash); if (histHash) { setThreadCookiePref(threadId, histHash); refreshCookiePrefSwitchState(); return; } } // ── 注入"饼干偏好"开关到回应模式行 ── // 清空饼干偏好开关区域:无串上下文时不应残留上一个串的开关/齿轮/圆圈着色状态 // 四处共用——发串模式、回复模式无串上下文、重置按钮、临时模式回复成功(漏一处即出现状态残留) function clearCookieCheckSwitch() { const area = document.querySelector('.xdex-cookie-check-area'); if (area) area.innerHTML = ''; } function injectCookieCheckSwitch(threadId) { // 注入到 .xdex-cookie-check-area(串内页和板块页共用) let area = document.querySelector('.xdex-cookie-check-area'); // 串内页没有该区域时,在回应模式行的 uk-width-4-5 中创建 if (!area) { // 优先找回应模式行内的输入区域,回退到第一个含回应模式标题的行 const replyTitle = Array.from(document.querySelectorAll('.h-post-form-title')).find(el => el.textContent.trim() === '回应模式'); const replyRow = (replyTitle ? replyTitle.closest('.h-post-form-grid') : null)?.querySelector('.h-post-form-input') || document.querySelector('.h-post-form-grid .h-post-form-input.uk-width-3-5'); if (!replyRow) return; area = document.createElement('span'); area.className = 'xdex-cookie-check-area'; area.style.cssText = 'display:inline-flex;align-items:center;gap:2px;flex-shrink:0;margin-left:auto;'; // 让父容器成为 flex 使串号和开关同行,空间分布 // 扩展到 4/5 宽度,与板块页一致 replyRow.classList.remove('uk-width-3-5'); replyRow.classList.add('uk-width-4-5'); replyRow.style.display = 'flex'; replyRow.style.alignItems = 'center'; // 串号 font 元素撑满,把 area 推到右侧 const fontEl = replyRow.querySelector('font'); if (fontEl) fontEl.style.flex = '1'; replyRow.appendChild(area); } const pref = getThreadCookiePref(threadId); const isEnabled = !!pref; area.innerHTML = '<button type="button" class="xdex-edit-default-btn" title="修改默认饼干" style="visibility:' + (isEnabled ? 'visible' : 'hidden') + ';pointer-events:' + (isEnabled ? 'auto' : 'none') + ';">' + XDEX_ICON_GEAR + '</button>' + '<input type="checkbox" class="xdex-switch xdex-cookie-check-sw" role="switch" data-thread-id="' + threadId + '"' + (isEnabled ? ' checked' : '') + '>' + '<label style="font-size:11px;cursor:pointer;white-space:nowrap;">饼干偏好</label>'; const switchEl = area.querySelector('.xdex-cookie-check-sw'); const editBtn = area.querySelector('.xdex-edit-default-btn'); switchEl.addEventListener('change', () => { if (switchEl.checked) { if (!getThreadCookiePref(threadId)) { // 智能选择默认饼干:PO主 > 发言历史 > 当前饼干 const poHash = detectPOCookieHash(); const histHash = detectHistoryCookieHash(threadId); const cur = getCurrentCookie(); const defaultHash = poHash || histHash || (cur ? abbreviateName(cur.name) : ''); if (defaultHash) setThreadCookiePref(threadId, defaultHash); } editBtn.style.visibility = 'visible'; editBtn.style.pointerEvents = 'auto'; } else { removeThreadCookiePref(threadId); editBtn.style.visibility = 'hidden'; editBtn.style.pointerEvents = 'none'; } refreshCookiePrefSwitchState(); }); editBtn.addEventListener('click', () => { showCookieConfirmDialog(threadId, (selectedHash) => { setThreadCookiePref(threadId, selectedHash); const _c = findCookieByHash(getCookiesList(), selectedHash); const _n = _c ? abbreviateName(_c.name || '').replace(/ - 0000-00-00 00:00:00$/g, '').trim() : selectedHash; toast('已将 ' + _n + ' 设为本串默认饼干'); refreshCookiePrefSwitchState(); }, () => {}, { mode: 'setDefault' }); }); // 注入/重建后立即按当前饼干与偏好的一致性着色 refreshCookiePrefSwitchState(); } /* -------------------------------------------------- * tag 26. 阅图模式 * -------------------------------------------------- */ // ── 图片阅览器状态 ── const ImageViewer = { active: false, threadId: '', poHash: '', // PO主的饼干has totalPageCount: 0, gridImages: [], // {id, imgUrl, thumbUrl, isGif, userHash, name, title, content, contentRaw, now, page} // contentRaw: 原始HTML,用于详情页显示 fetchedPages: new Set(), // 已请求过的页码(防重复请求,包含无图片的页) loadedPages: new Set(), // 有图片的已加载页码(用于显示"已加载 X 页") loading: false, currentDetailIndex: -1, // 当前详情查看的图片索引 rotation: 0, scale: 1, panX: 0, panY: 0, gridScrollTop: 0, // 返回瀑布流时恢复滚动位置 // 向上翻页相关 upwardEnabled: false, // 向上翻页模式是否开启 startPage: 0, // 打开瀑布流时的起始页码 _upwardRenderedCount: 0, // 向上容器已渲染的图片数 renderedKeys: new Set(), // 已渲染到 DOM 的图片 id(防重复渲染) }; function getImageViewerApiUrl(threadId, page) { return `https://api.nmb.best/api/thread?id=${encodeURIComponent(threadId)}&page=${encodeURIComponent(page)}`; } const XV_MAX_DISPLAY_HEIGHT = 500; function buildImageUrls(item) { const ext = (item.ext || '').toLowerCase(); const isGif = ext === '.gif'; const imgUrl = `https://image.nmb.best/image/${item.img}${item.ext}`; const thumbUrl = isGif ? imgUrl : `https://image.nmb.best/thumb/${item.img}${item.ext}`; return { imgUrl, thumbUrl, isGif }; } // ── 预加载缩略图尺寸 ── function preloadThumbnailSizes() { const images = ImageViewer.gridImages; const toLoad = images.filter(img => !img._thumbW); if (!toLoad.length) return Promise.resolve(); return Promise.allSettled(toLoad.map(img => new Promise((resolve) => { const probe = new Image(); probe.onload = () => { img._thumbW = probe.naturalWidth || 200; img._thumbH = probe.naturalHeight || 200; // 计算在列宽下的显示高度 const ratio = img._thumbH / img._thumbW; const cw = ImageViewer._colWidth || 200; let displayH = Math.round(cw * ratio); // 长图限高 if (ratio > 3) { displayH = 500; img._isLong = true; } img._displayH = displayH; resolve(); }; probe.onerror = () => { img._thumbW = 200; img._thumbH = 200; img._displayH = 200; resolve(); }; probe.src = img.thumbUrl; }))); } function buildGridCard(img, index) { const card = document.createElement('div'); card.className = 'xv-grid-card'; card.dataset.imgId = img.id; card.style.cssText = 'border:1px solid var(--border,#333);border-radius:6px;overflow:hidden;cursor:pointer;background:var(--card,#1a1a1a);transition:border-color .15s;'; const wrapper = document.createElement('div'); wrapper.style.cssText = img._displayH ? 'position:relative;overflow:hidden;height:' + img._displayH + 'px;' : 'position:relative;overflow:hidden;'; const imgEl = document.createElement('img'); imgEl.src = img.thumbUrl; imgEl.loading = 'lazy'; imgEl.style.cssText = img._displayH ? 'width:100%;height:100%;object-fit:cover;display:block;' : 'width:100%;height:auto;display:block;'; wrapper.appendChild(imgEl); // GIF 角标 if (img.isGif) { const gifBadge = document.createElement('span'); gifBadge.style.cssText = 'position:absolute;top:4px;left:4px;background:rgba(0,0,0,.7);color:#0f0;font-size:10px;padding:1px 4px;border-radius:3px;z-index:1;'; gifBadge.textContent = 'GIF'; wrapper.appendChild(gifBadge); } // 长图角标(初始隐藏,onload 后判定) const longBadge = document.createElement('span'); longBadge.style.cssText = 'position:absolute;' + (img.isGif ? 'top:20px;' : 'top:4px;') + 'left:4px;background:rgba(255,193,7,.85);color:#000;font-size:10px;padding:1px 4px;border-radius:3px;z-index:1;display:none;'; longBadge.textContent = '长图'; wrapper.appendChild(longBadge); card.appendChild(wrapper); const footer = document.createElement('div'); footer.style.cssText = 'padding:4px 6px;display:flex;justify-content:space-between;align-items:center;'; footer.innerHTML = `<span style="font-size:11px;color:var(--muted-foreground,#888);">No.${img.id}</span><span style="font-size:10px;color:var(--muted-foreground,#666);">P${img.page}·${img.pageIdx}</span>`; card.appendChild(footer); // 图片加载后:判定宽高比和长图,必要时切换原图、限高 imgEl.addEventListener('load', function onLoad() { imgEl.removeEventListener('load', onLoad); const w = imgEl.naturalWidth || 1; const h = imgEl.naturalHeight || 1; const ratio = Math.max(w / h, h / w); // 宽高比 >3 的长图/宽图 → 限制最大高度,标记角标 if (h > w && h / w > 3) { longBadge.style.display = ''; wrapper.style.maxHeight = XV_MAX_DISPLAY_HEIGHT + 'px'; imgEl.style.maxHeight = XV_MAX_DISPLAY_HEIGHT + 'px'; imgEl.style.objectFit = 'cover'; } // 缩略图太小或宽高比极端 → 换原图 if (imgEl.naturalWidth < 60 || ratio > 3) { imgEl.src = img.imgUrl; } }); card.addEventListener('click', () => { // 用图片 ID 查找当前索引(排序后旧 index 会失效) const idx = ImageViewer.gridImages.findIndex(g => g.id === img.id); openDetailLayer(idx >= 0 ? idx : index); }); return card; } function stripHtml(html) { const tmp = document.createElement('div'); tmp.innerHTML = html || ''; return (tmp.textContent || '').trim(); } async function fetchThreadPage(threadId, page) { const url = getImageViewerApiUrl(threadId, page); const resp = await gmRequest(url, 'json'); const data = resp && (resp.response || resp.responseText); if (!data || typeof data === 'string') { try { return JSON.parse(data); } catch (e) { return null; } } return data; } async function loadViewerPages(threadId, startPage, count) { const pages = []; for (let i = 0; i < count; i++) { const p = startPage + i; if (ImageViewer.fetchedPages.has(p) || p < 1) continue; pages.push(p); } if (!pages.length) return; const results = await Promise.allSettled( pages.map(p => fetchThreadPage(threadId, p)) ); let appended = 0; results.forEach((r, i) => { const page = pages[i]; if (r.status !== 'fulfilled' || !r.value) return; const thread = r.value; const replies = Array.isArray(thread.Replies) ? thread.Replies : []; // 记录PO主hash(从串的user_hash获取) if (!ImageViewer.poHash && thread.user_hash) ImageViewer.poHash = thread.user_hash; ImageViewer.fetchedPages.add(page); // 标记已请求 let pageIdx = 0; replies.forEach(rep => { if (!rep || !rep.img || !rep.ext || rep.id === 9999999) return; const { imgUrl, thumbUrl, isGif } = buildImageUrls(rep); pageIdx++; ImageViewer.gridImages.push({ id: rep.id, imgUrl, thumbUrl, isGif, userHash: rep.user_hash || '', name: rep.name || '', title: rep.title || '', content: stripHtml(rep.content || ''), contentRaw: rep.content || '', now: rep.now || '', page, pageIdx }); appended++; }); if (appended > 0) ImageViewer.loadedPages.add(page); // 只有含图片的页才计入已加载页数 // 按页码+页内序号排序,保证 gridImages 始终是页码顺序(向上翻页加载的页面也能正确插入) ImageViewer.gridImages.sort((a, b) => a.page - b.page || (a.pageIdx || 0) - (b.pageIdx || 0)); // 详情页打开时,sort 会改变索引,通过图片 ID 重新定位 if (ImageViewer.currentDetailIndex >= 0 && ImageViewer._currentDetailImageId) { const newIdx = ImageViewer.gridImages.findIndex(g => g.id === ImageViewer._currentDetailImageId); if (newIdx >= 0) ImageViewer.currentDetailIndex = newIdx; } const replyCount = Number(thread.ReplyCount || replies.length); if (replyCount > 0) { const impliedTotal = Math.ceil(replyCount / 19); if (impliedTotal > ImageViewer.totalPageCount) ImageViewer.totalPageCount = impliedTotal; } }); return appended; } // ── 入口:串内页添加"阅图"按钮 ── const IMAGE_VIEWER_TIPS_BTN_URL = 'https://file.moetu.org/images/2023/08/29/e1db89f8b15b0002b1ebdf574d5a7f30c6f2590891da1a8422eba303bf8b589c.gif'; function ensureImageViewerButtonStyle() { if (document.getElementById('xdex-image-viewer-btn-style')) return; const style = document.createElement('style'); style.id = 'xdex-image-viewer-btn-style'; style.textContent = ` .xdex-image-viewer-btn { position: fixed; top: 10px; right: 52px; z-index: 10000; width: 28px; height: 28px; padding: 0; margin: 0; border: none; border-radius: 50%; background: #99ffff; color: #000; font-size: 13px; font-weight: bold; cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,.18); transition: transform .15s, box-shadow .15s, background .15s, filter .15s ease, opacity .15s ease; display: flex; align-items: center; justify-content: center; overflow: hidden; line-height: 1; } .xdex-image-viewer-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,.22); } .xdex-image-viewer-btn.xdex-iv-btn-hidden { display: none !important; } /* 与帖内模糊一致:默认模糊,悬停看清「图」字,点击不受影响 */ .xdex-image-viewer-btn.xdex-iv-btn-blur { filter: blur(14px) brightness(0.5); } .xdex-image-viewer-btn.xdex-iv-btn-blur:hover { filter: none; } .xdex-image-viewer-btn.xdex-iv-btn-tips { background: #99ffff; color: transparent; font-size: 0; } .xdex-image-viewer-btn .xdex-iv-btn-tips-img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; pointer-events: none; display: block; } .xdex-image-viewer-btn svg { width: 18px; height: 18px; display: block; pointer-events: none; } #xdex-tombstone-mark { position: fixed; top: 44px; right: 52px; z-index: 10000; width: 28px; height: 28px; padding: 0; margin: 0; border: none; border-radius: 50%; background: #F0E0D6; color: #5b4636; cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,.18); display: flex; align-items: center; justify-content: center; overflow: hidden; line-height: 1; } #xdex-tombstone-mark:hover { background: #e8d5c5; color: #c62828; } #xdex-tombstone-mark svg { width: 15px; height: 15px; display: block; pointer-events: none; } `; (document.head || document.documentElement).appendChild(style); } // 阅图模式「分隔线」子设置:控制页码分隔线与“第N页”标识的显示(跨页面实时同步) function applyImageViewerSeparatorSetting(enabled) { let on = enabled; if (typeof on !== 'boolean') { try { on = !(typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state && SettingPanel.state.imageViewerSeparators === false); } catch (e) { on = true; } } const overlay = document.getElementById('xdex-image-viewer'); if (!overlay) return; overlay.classList.toggle('xdex-iv-no-sep', !on); } function getCurrentImageHideModeForViewerBtn() { const live = window.__xdexImageHideMode; if (live && live !== 'none') return live; try { const cfg = (typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state) ? SettingPanel.state : (typeof SettingPanel !== 'undefined' && SettingPanel ? Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})) : null); const mode = cfg && cfg.applyImageHideMode; if (mode === 'blur' || mode === 'noimage' || mode === 'tips' || mode === 'default') return mode; } catch (e) {} return 'default'; } function getCurrentThreadIdForImageViewer() { return PageType.getThreadId(false); } function isImageViewerThreadPage() { return PageType.isThreadPage(false); } function syncImageViewerButtonForHideMode(mode) { const IMAGE_VIEWER_ICON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="9" cy="9" r="2"></circle><path d="M21 16l-4.6-4.6a2 2 0 0 0-2.8 0L5 20"></path></svg>'; ensureImageViewerButtonStyle(); const btn = document.getElementById('xdex-image-viewer-btn'); if (!btn) return; const finalMode = (mode === 'blur' || mode === 'noimage' || mode === 'tips') ? mode : 'default'; btn.classList.remove('xdex-iv-btn-hidden', 'xdex-iv-btn-blur', 'xdex-iv-btn-tips'); const tipsImg = btn.querySelector('.xdex-iv-btn-tips-img'); if (tipsImg) tipsImg.remove(); // 恢复默认文字入口 btn.innerHTML = IMAGE_VIEWER_ICON_SVG; btn.setAttribute('title', '阅图模式'); btn.setAttribute('aria-label', '阅图模式'); btn.style.display = ''; if (finalMode === 'noimage') { // 隐藏但可恢复:仅加 class,不删节点 btn.classList.add('xdex-iv-btn-hidden'); btn.setAttribute('aria-hidden', 'true'); return; } btn.removeAttribute('aria-hidden'); if (finalMode === 'blur') { btn.classList.add('xdex-iv-btn-blur'); return; } if (finalMode === 'tips') { btn.classList.add('xdex-iv-btn-tips'); btn.textContent = ''; const img = document.createElement('img'); img.className = 'xdex-iv-btn-tips-img'; img.src = IMAGE_VIEWER_TIPS_BTN_URL; img.alt = '阅图模式'; img.draggable = false; btn.appendChild(img); btn.setAttribute('title', '阅图模式(Tips)'); btn.setAttribute('aria-label', '阅图模式'); } } function openImageViewerFromSettingsPanel() { if (!isImageViewerThreadPage()) { if (typeof toast === 'function') toast('请在串内页面使用阅图模式'); return; } const threadId = getCurrentThreadIdForImageViewer(); if (!threadId) { if (typeof toast === 'function') toast('未识别到当前串号'); return; } // 无图模式下右上角入口隐藏,设置面板“打开”作为备选 try { const $cover = $('#sp_cover'); if ($cover.length && $cover.is(':visible')) $cover.fadeOut(120); } catch (e) {} openImageViewer(threadId); } function injectImageViewerButton() { if (!isImageViewerThreadPage()) return; ensureImageViewerButtonStyle(); const threadId = getCurrentThreadIdForImageViewer(); if (!threadId) return; let btn = document.getElementById('xdex-image-viewer-btn'); if (!btn) { const IMAGE_VIEWER_ICON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="9" cy="9" r="2"></circle><path d="M21 16l-4.6-4.6a2 2 0 0 0-2.8 0L5 20"></path></svg>'; const $btn = $('<button type="button" id="xdex-image-viewer-btn" class="xdex-image-viewer-btn" title="阅图模式" aria-label="阅图模式">').html(IMAGE_VIEWER_ICON_SVG); $btn.on('click', () => openImageViewer(threadId)); $('body').append($btn); btn = $btn[0]; } // 按当前隐藏模式同步外观(含无图隐藏可恢复) syncImageViewerButtonForHideMode(getCurrentImageHideModeForViewerBtn()); } // ── 回收站墓碑标识: 浏览已被移入回收站的串时, 在阅图按钮下方展示垃圾桶标识 ── function injectThreadTombstoneMark() { const MARK_ID = 'xdex-tombstone-mark'; let mark = document.getElementById(MARK_ID); if (!isImageViewerThreadPage()) { if (mark) mark.remove(); return; } const threadId = getCurrentThreadIdForImageViewer(); if (!threadId) { if (mark) mark.remove(); return; } const tombs = getThreadHistoryStore().tombstones || {}; const suffix = String(threadId).slice(0, 8); const candidates = ['normal:' + suffix, 'reply:' + suffix]; let hitKey = null, tomb = null; for (const k of candidates) { if (tombs[k] && !tombs[k].purged && !tombs[k].revivedAt) { hitKey = k; tomb = tombs[k]; break; } } if (!tomb) { if (mark) mark.remove(); return; } if (!mark) { mark = document.createElement('button'); mark.type = 'button'; mark.id = MARK_ID; mark.innerHTML = XDEX_SVG_TRASH; mark.dataset.recycleKey = hitKey; mark.addEventListener('click', function (e) { e.preventDefault(); const key = this.dataset.recycleKey || hitKey; try { openSettingsPanelModuleTab('history'); } catch (err) {} setTimeout(() => openRecycleBinAndLocate(key), 150); }); document.body.appendChild(mark); } const daysLeft = Math.max(0, Math.ceil((Number(tomb.deletedAt) + THREAD_HISTORY_TOMBSTONE_TTL_MS - Date.now()) / 86400000)); mark.title = `历史浏览数据在回收站中 · 约 ${daysLeft} 天后自动彻底清除 · 点击查看`; mark.setAttribute('aria-label', mark.title); } // ── 打开阅览器 ── function getImageViewerStartPage() { // 路径 /page/N 优先,再回退 ?page=(阅图语义) return PageType.getPathPageNum(); } function renderGridImages() { const overlay = document.getElementById('xdex-image-viewer'); if (!overlay) return; const info = overlay.querySelector('.xv-header-info'); const masonry = overlay.querySelector('.xv-masonry'); if (!masonry) return; const images = ImageViewer.gridImages; // 确保有一组列(只创建一次) let cols = Array.from(masonry.querySelectorAll('.xv-masonry-col')); if (cols.length === 0) { const { numCols, colWidth, gap } = getGridLayout(); ImageViewer._colWidth = colWidth; ImageViewer._gap = gap; for (let c = 0; c < numCols; c++) { const col = document.createElement('div'); col.className = 'xv-masonry-col'; col.style.cssText = 'flex:0 0 ' + colWidth + 'px;display:flex;flex-direction:column;gap:' + gap + 'px;'; masonry.appendChild(col); cols.push(col); } } // 找到最后一个已插入分隔线的页码 let lastSepPage = 0; const existingSeps = masonry.querySelectorAll('.xv-page-separator'); if (existingSeps.length > 0) { lastSepPage = parseInt(existingSeps[existingSeps.length - 1].dataset.page) || 0; } // 收集尚未渲染到 DOM 的新图片(用图片 id 去重,避免数组混杂时重复渲染) const newItems = []; for (let i = 0; i < images.length; i++) { if (!ImageViewer.renderedKeys.has(images[i].id)) { newItems.push({ img: images[i], index: i }); } } const pageGroups = new Map(); for (const item of newItems) { const p = item.img.page; if (!pageGroups.has(p)) pageGroups.set(p, []); pageGroups.get(p).push(item); } // 按页处理:LPT 全局最优分配 const gap = 8; for (const [pageNum, pageItems] of pageGroups) { // 先插入分隔线(此时列高 = 上一页图片底部,第一页时 = 0) if (pageNum > lastSepPage) { const colHeights = cols.map(c => c.offsetHeight); masonry.appendChild(buildPageSeparator(pageNum, colHeights, cols[0].offsetWidth)); lastSepPage = pageNum; } // 按显示高度降序排列(大图优先) pageItems.sort((a, b) => { const ha = a.img._displayH || 200; const hb = b.img._displayH || 200; return hb - ha; }); // 用虚拟高度做全局分配,避免逐张贪心 const vHeights = cols.map(c => c.offsetHeight); for (const item of pageItems) { let minIdx = 0; for (let c = 1; c < vHeights.length; c++) { if (vHeights[c] < vHeights[minIdx]) minIdx = c; } const card = buildGridCard(item.img, item.index); cols[minIdx].appendChild(card); ImageViewer.renderedKeys.add(item.img.id); // 标记已渲染 vHeights[minIdx] += (item.img._displayH || 200) + gap; } } info.textContent = '\u4e32\u53f7 ' + ImageViewer.threadId + ' \u00b7 \u5df2\u52a0\u8f7d ' + images.length + ' \u5f20\u56fe \u00b7 ' + ImageViewer.loadedPages.size + ' \u9875'; } function openImageViewer(threadId) { if (ImageViewer.active) return; ImageViewer.active = true; ImageViewer.threadId = threadId; ImageViewer.poHash = ''; ImageViewer.gridImages = []; ImageViewer.fetchedPages = new Set(); // 已请求过的页码(含无图片页) ImageViewer.loadedPages = new Set(); // 有图片的已加载页码 ImageViewer.loading = false; ImageViewer.currentDetailIndex = -1; ImageViewer.totalPageCount = 0; ImageViewer.gridScrollTop = 0; ImageViewer.upwardEnabled = false; ImageViewer._upwardRenderedCount = 0; ImageViewer.renderedKeys.clear(); ImageViewer.startPage = getImageViewerStartPage(); ImageViewer._upwardResizeObserver = null; ImageViewer._pendingGridRender = false; ImageViewer._pendingUpwardPages = new Set(); // 详情页期间加载的向上页 document.body.style.overflow = 'hidden'; const startPage = getImageViewerStartPage(); renderGridLayer(); loadViewerPages(threadId, startPage, 2).then(async () => { await preloadThumbnailSizes(); renderGridImages(); // 图片太少撑不满视口时,自动继续加载直到填满或没有更多页 const scroll = document.querySelector('.xv-grid-scroll'); const maxIter = 20; // 防止无限循环 for (let i = 0; i < maxIter; i++) { if (!scroll || scroll.scrollHeight > scroll.clientHeight + 10) break; const maxFetched = Math.max(...Array.from(ImageViewer.fetchedPages), 0); if (ImageViewer.totalPageCount > 0 && maxFetched >= ImageViewer.totalPageCount) break; await new Promise(r => setTimeout(r, 300)); // 限速,避免触发服务端风控 const loaded = await loadViewerPages(threadId, maxFetched + 1, 2); if (!loaded) break; await preloadThumbnailSizes(); renderGridImages(); } }); } // ── 关闭阅览器 ── function closeImageViewer() { ImageViewer.active = false; ImageViewer.threadId = ''; ImageViewer.gridImages = []; ImageViewer.fetchedPages = new Set(); ImageViewer.loadedPages = new Set(); ImageViewer.currentDetailIndex = -1; ImageViewer.renderedKeys.clear(); ImageViewer.rotation = 0; ImageViewer.scale = 1; ImageViewer.panX = 0; ImageViewer.panY = 0; if (ImageViewer._upwardResizeObserver) { ImageViewer._upwardResizeObserver.disconnect(); ImageViewer._upwardResizeObserver = null; } document.body.style.overflow = ''; const overlay = document.getElementById('xdex-image-viewer'); if (overlay) overlay.remove(); document.removeEventListener('keydown', viewerKeyHandler); } // ── 瀑布流层:骨架 ── function renderGridLayer() { const existing = document.getElementById('xdex-image-viewer'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'xdex-image-viewer'; overlay.style.cssText = 'position:fixed;inset:0;z-index:10001;background:var(--background,#111);display:flex;flex-direction:column;overscroll-behavior:contain;'; overlay.innerHTML = ` <div class="xv-header" style="display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:var(--card,#1a1a1a);border-bottom:1px solid var(--border,#333);flex-shrink:0;"> <span class="xv-header-info" style="color:var(--foreground,#ccc);font-size:13px;">串号 ${ImageViewer.threadId} · 正在加载…</span> <div style="display:flex;gap:6px;align-items:center;"><button class="xv-upward-btn" style="padding:4px 10px;background:#666;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;opacity:0.6;" title="开启向上翻页">▲</button><button class="xv-close-btn" style="padding:4px 10px;background:var(--destructive,#EE0000);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">退出</button></div> </div> <div class="xv-grid-scroll" style="flex:1;overflow-y:auto;overflow-anchor:none;padding:12px;"> <div class="xv-masonry-upward" style="display:flex;gap:8px;align-items:flex-end;justify-content:center;position:relative;width:100%;"></div> <div class="xv-masonry-anchor" style="height:0;width:100%;flex-shrink:0;"></div> <div class="xv-masonry" style="display:flex;gap:8px;align-items:flex-start;justify-content:center;position:relative;width:100%;"></div> </div> `; document.body.appendChild(overlay); try { applyImageViewerSeparatorSetting(); } catch (e) {} // 拦截滚轮和触摸,防止穿透到原页面 overlay.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true }); overlay.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: true }); overlay.querySelector('.xv-close-btn').addEventListener('click', closeImageViewer); const scroll = overlay.querySelector('.xv-grid-scroll'); // 在真正滚动容器上拦截默认行为,防止滚到边界时穿透到底层页面 scroll.addEventListener('wheel', (e) => { const atTop = scroll.scrollTop <= 0; const atBottom = scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 1; if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { e.preventDefault(); } }, { passive: false }); scroll.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false }); scroll.addEventListener('scroll', () => { if (ImageViewer.loading) return; // 向下加载:接近底部时触发 if (scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 400) { loadNextGridPages(); } // 向上加载:开启向上模式且接近顶部时触发 if (ImageViewer.upwardEnabled && scroll.scrollTop <= 400) { loadPrevGridPages(); } }); // 向上翻页按钮事件 const upwardBtn = overlay.querySelector('.xv-upward-btn'); if (upwardBtn) { upwardBtn.addEventListener('click', () => { ImageViewer.upwardEnabled = !ImageViewer.upwardEnabled; if (ImageViewer.upwardEnabled) { upwardBtn.style.opacity = '1'; upwardBtn.style.background = 'var(--primary,#006666)'; upwardBtn.title = '关闭向上翻页'; toast('已开启向上翻页'); // 立即触发一次向上加载 loadPrevGridPages(); } else { upwardBtn.style.opacity = '0.6'; upwardBtn.style.background = '#666'; upwardBtn.title = '开启向上翻页'; toast('已关闭向上翻页'); } }); } } // ── 瀑布流:获取或创建列容器 ── function getGridLayout() { const masonry = document.querySelector('.xv-masonry'); if (!masonry) return { numCols: 4, colWidth: 200, gap: 8 }; const containerWidth = masonry.clientWidth || 800; const gap = 8; const targetColWidth = 200; let numCols = Math.max(1, Math.floor((containerWidth + gap) / (targetColWidth + gap))); // 拉伸列宽填满容器,但限制在合理范围 let colWidth = Math.floor((containerWidth - (numCols - 1) * gap) / numCols); colWidth = Math.max(160, Math.min(300, colWidth)); return { numCols, colWidth, gap }; } function pickShortestColumn(vHeights) { let minIdx = 0; for (let c = 1; c < vHeights.length; c++) { if (vHeights[c] < vHeights[minIdx]) minIdx = c; } return minIdx; } function pickUpwardGapColumn(vHeights, usedCols) { // 向上容器是 flex-end:列高越短,视觉顶部空白越大,少图页应优先补这些空白。 const containerH = Math.max(...vHeights, 1); let bestIdx = -1; let bestGap = -Infinity; for (let c = 0; c < vHeights.length; c++) { if (usedCols && usedCols.has(c) && usedCols.size < vHeights.length) continue; const gap = containerH - vHeights[c]; if (gap > bestGap) { bestGap = gap; bestIdx = c; } } if (bestIdx >= 0) return bestIdx; return pickShortestColumn(vHeights); } function buildPageSeparator(pageNum, colHeights, colWidth) { colWidth = colWidth || (ImageViewer._colWidth || 200); const gap = 8; const totalW = (colHeights && colHeights.length > 0) ? colHeights.length * colWidth + (colHeights.length - 1) * gap : colWidth; const sep = document.createElement('div'); sep.className = 'xv-page-separator'; sep.dataset.page = String(pageNum); if (!colHeights || colHeights.length < 2) { const minH = colHeights && colHeights.length ? Math.min(...colHeights) : 0; sep.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);width:' + totalW + 'px;top:' + minH + 'px;pointer-events:none;z-index:2;text-align:center;'; sep.innerHTML = '<span style="display:inline-block;background:var(--background,#111);color:#EE0000;font-size:12px;font-weight:600;padding:2px 8px;">第' + pageNum + '页</span>'; return sep; } const maxH = Math.max(...colHeights); const minH = Math.min(...colHeights); const contourH = Math.max(maxH - minH, 1); const points = []; for (let c = 0; c < colHeights.length; c++) { const x1 = c * (colWidth + gap); const x2 = x1 + colWidth; const y = colHeights[c] - minH; points.push(x1 + ',' + y); points.push(x2 + ',' + y); } const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', String(totalW)); svg.setAttribute('height', String(contourH)); svg.setAttribute('viewBox', '0 0 ' + totalW + ' ' + contourH); svg.setAttribute('preserveAspectRatio', 'none'); svg.style.cssText = 'display:block;'; const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); polyline.setAttribute('points', points.join(' ')); polyline.setAttribute('fill', 'none'); polyline.setAttribute('stroke', '#EE0000'); polyline.setAttribute('stroke-width', '2'); polyline.setAttribute('vector-effect', 'non-scaling-stroke'); svg.appendChild(polyline); sep.appendChild(svg); // label 放在 polyline 水平中点的实际路径上 const midX = totalW / 2; let labelY = 0; for (let i = 0; i < points.length - 2; i += 2) { const x1 = parseFloat(points[i].split(',')[0]); const y1 = parseFloat(points[i].split(',')[1]); const x2 = parseFloat(points[i + 1].split(',')[0]); const y2 = parseFloat(points[i + 1].split(',')[1]); const x3 = parseFloat(points[i + 2].split(',')[0]); const y3 = parseFloat(points[i + 2].split(',')[1]); // 检查 midX 是否在 segment (x2,y2)→(x3,y3) 之间 if (midX >= x2 && midX <= x3) { const t = (x3 !== x2) ? (midX - x2) / (x3 - x2) : 0; labelY = y2 + t * (y3 - y2); break; } // 检查 midX 是否在 segment (x1,y1)→(x2,y2) 之间 if (midX >= x1 && midX <= x2) { const t = (x2 !== x1) ? (midX - x1) / (x2 - x1) : 0; labelY = y1 + t * (y2 - y1); break; } } const label = document.createElement('span'); label.style.cssText = 'position:absolute;left:50%;top:' + Math.round(labelY) + 'px;transform:translate(-50%,-50%);background:var(--background,#111);color:#EE0000;font-size:12px;font-weight:600;padding:0 8px;white-space:nowrap;z-index:1;'; label.textContent = '第' + pageNum + '页'; sep.appendChild(label); // 绝对定位:top = 最矮列高度 sep.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);width:' + totalW + 'px;top:' + minH + 'px;height:' + contourH + 'px;pointer-events:none;z-index:2;'; return sep; } // ── 瀑布流层:渲染图片卡片(瀑布流,每页一组列) ── // ── 向上翻页:插入图片到瀑布流顶部 ── // ── 向上翻页:插入图片到独立的向上容器 ── function prependGridImages(pageNum) { const overlay = document.getElementById('xdex-image-viewer'); if (!overlay) return; const upwardContainer = overlay.querySelector('.xv-masonry-upward'); if (!upwardContainer) return; const images = ImageViewer.gridImages; const gap = 8; // 确保向上容器有一组列 let cols = Array.from(upwardContainer.querySelectorAll('.xv-masonry-col')); if (cols.length === 0) { const { numCols, colWidth } = getGridLayout(); ImageViewer._colWidth = colWidth; // 同步列宽,保证 SVG 等高线点位正确 for (let c = 0; c < numCols; c++) { const col = document.createElement('div'); col.className = 'xv-masonry-col'; col.style.cssText = 'flex:0 0 ' + colWidth + 'px;display:flex;flex-direction:column;align-items:stretch;gap:' + gap + 'px;'; upwardContainer.appendChild(col); cols.push(col); } } // 记录插入前列高度 const colHeightsBefore = cols.map(c => c.offsetHeight); const containerHBefore = Math.max(...colHeightsBefore, 1); // console.log("[xv-pg] BEFORE insert", "containerHBefore=" + containerHBefore, "colHeights=" + JSON.stringify(colHeightsBefore)); // 收集该页图片(过滤掉已渲染的,避免重复) const pageItems = images .map((img, index) => ({ img, index })) .filter(item => item.img.page === pageNum && !ImageViewer.renderedKeys.has(item.img.id)); if (!pageItems.length) return 0; // 该页所有图片已渲染,直接跳过,避免 scrollTop += undefined // 按显示高度降序排列 pageItems.sort((a, b) => (b.img._displayH || 200) - (a.img._displayH || 200)); // LPT 分配到各列(插入到列最前面) // 用真实列高做 LPT 基准,这样新图片会优先填到最矮的列(考虑旧内容) const vHeights = cols.map(c => c.offsetHeight); const isSparseUpwardPage = pageItems.length < cols.length; const usedCols = new Set(); for (const item of pageItems) { // 旧逻辑:所有向上页都按最短列 LPT 分配。少图页连续出现时,flex-end 顶部空白不会被优先补齐。 // const minIdx = pickShortestColumn(vHeights); const minIdx = isSparseUpwardPage ? pickUpwardGapColumn(vHeights, usedCols) : pickShortestColumn(vHeights); const card = buildGridCard(item.img, item.index); cols[minIdx].insertBefore(card, cols[minIdx].firstChild); ImageViewer.renderedKeys.add(item.img.id); // 标记已渲染 vHeights[minIdx] += (item.img._displayH || 200) + gap; if (isSparseUpwardPage) usedCols.add(minIdx); } // 分隔线:标记第 pageNum 页的顶部等高线 // // 上容器 flex-end 布局下,列顶部(视觉) = containerH - colHeight。 // 每次新页插入到列头部后,旧内容整体下移,旧分隔线的 top 需要同步增加。 // 关键性质:第 N 页图片在容器中的 y 坐标 = containerH_current - colHeight_after_insert_pageN, // 而这个值在后续页插入时只增加 delta = newContainerH - oldContainerH,polyline 形状不变。 // // 计算插入后容器高度 const uContainerH = Math.max(...cols.map(c => c.offsetHeight), 1); // console.log("[xv-pg] AFTER insert", "uContainerH=" + uContainerH, "delta=" + (uContainerH - containerHBefore), "colHeights=" + JSON.stringify(cols.map(c => c.offsetHeight))); // 更新所有已有分隔线:旧内容下移了 (uContainerH - containerHBefore) 像素 const delta = uContainerH - containerHBefore; if (delta !== 0) { const oldSeps = upwardContainer.querySelectorAll('.xv-page-separator'); oldSeps.forEach(sep => { const prevTop = parseFloat(sep.style.top) || 0; sep.style.top = (prevTop + delta) + 'px'; }); } // 当前页分隔线:该页首图顶部在容器中的 y 坐标 = uContainerH - 当前列高 const sepHeights = cols.map(c => uContainerH - c.offsetHeight); const separator = buildPageSeparator(pageNum, sepHeights, cols[0].offsetWidth); // 绝对定位元素 append 到 positioned ancestor 根(而非某一列内部) upwardContainer.appendChild(separator); // 记录向上容器已渲染的图片数 ImageViewer._upwardRenderedCount = upwardContainer.querySelectorAll('.xv-grid-card').length; return delta; // 返回向上容器高度增量,供调用方补偿滚动位置 // 更新 header const info = overlay.querySelector('.xv-header-info'); if (info) { info.textContent = '\u4e32\u53f7 ' + ImageViewer.threadId + ' \u00b7 \u5df2\u52a0\u8f7d ' + images.length + ' \u5f20\u56fe \u00b7 ' + ImageViewer.loadedPages.size + ' \u9875'; } } // ── 向下加载下一页 ── async function loadNextGridPages() { if (ImageViewer.loading) return; ImageViewer.loading = true; const scroll = document.querySelector(".xv-grid-scroll"); const maxIter = 20; for (let iter = 0; iter < maxIter; iter++) { const maxFetched = Math.max(...Array.from(ImageViewer.fetchedPages), 0); const nextPages = []; for (let i = 1; i <= 2; i++) { const p = maxFetched + i; if (!ImageViewer.fetchedPages.has(p)) nextPages.push(p); } if (!nextPages.length) break; if (iter > 0) await new Promise(r => setTimeout(r, 300)); // 限速 await loadViewerPages(ImageViewer.threadId, nextPages[0], nextPages.length); await preloadThumbnailSizes(); if (ImageViewer.currentDetailIndex >= 0) { ImageViewer._pendingGridRender = true; } else { renderGridImages(); } // 内容撑满视口后停止自动加载,交给滚动事件触发 if (scroll && scroll.scrollHeight > scroll.clientHeight + 10) break; } ImageViewer.loading = false; } // ── 向上加载上一页 ── async function loadPrevGridPages() { if (ImageViewer.loading) return; ImageViewer.loading = true; const scroll = document.querySelector(".xv-grid-scroll"); const maxIter = 20; for (let iter = 0; iter < maxIter; iter++) { const minFetched = Math.min(...Array.from(ImageViewer.fetchedPages)); if (minFetched <= 1) break; const prevPage = minFetched - 1; if (ImageViewer.fetchedPages.has(prevPage)) break; if (iter > 0) await new Promise(r => setTimeout(r, 300)); await loadViewerPages(ImageViewer.threadId, prevPage, 1); await preloadThumbnailSizes(); if (ImageViewer.currentDetailIndex >= 0) { ImageViewer._pendingUpwardPages.add(prevPage); } else { const upwardDelta = prependGridImages(prevPage); scroll.scrollTop += upwardDelta; if (ImageViewer._lastUpwardH !== undefined) { ImageViewer._lastUpwardH = scroll.querySelector(".xv-masonry-upward").offsetHeight; } } // 内容撑满视口后停止自动加载 if (scroll && scroll.scrollHeight > scroll.clientHeight + 10) break; } // ResizeObserver 初始化(只执行一次) const upwardContainer = scroll.querySelector(".xv-masonry-upward"); if (upwardContainer && !ImageViewer._upwardResizeObserver) { ImageViewer._lastUpwardH = upwardContainer.offsetHeight; ImageViewer._upwardResizeObserver = new ResizeObserver(() => { if (!ImageViewer.active) return; const newH = upwardContainer.offsetHeight; const dh = newH - ImageViewer._lastUpwardH; ImageViewer._lastUpwardH = newH; if (dh > 0) scroll.scrollTop += dh; }); ImageViewer._upwardResizeObserver.observe(upwardContainer); } // 如果已经加载到第1页,自动关闭向上模式 if (Math.min(...Array.from(ImageViewer.fetchedPages)) <= 1) { ImageViewer.upwardEnabled = false; const upwardBtn = document.querySelector(".xv-upward-btn"); if (upwardBtn) { upwardBtn.style.opacity = "0.4"; upwardBtn.style.background = "#444"; upwardBtn.title = "已到第1页"; upwardBtn.disabled = true; } toast("已加载到第1页"); } ImageViewer.loading = false; } // ── 详情层:打开 ── function openDetailLayer(index) { ImageViewer.currentDetailIndex = index; ImageViewer._currentDetailImageId = ImageViewer.gridImages[index]?.id; ImageViewer.rotation = 0; ImageViewer.scale = 1; ImageViewer.panX = 0; ImageViewer.panY = 0; ImageViewer.gridScrollTop = document.querySelector('.xv-grid-scroll')?.scrollTop || 0; renderDetailLayer(); document.addEventListener('keydown', viewerKeyHandler); prefetchAdjacentImages(index); } // ── 详情层:关闭回到瀑布流 ── function closeDetailLayer() { // 保存当前图片 ID(用于返回瀑布流后滚动定位) const _returnToImageId = ImageViewer._currentDetailImageId; ImageViewer.currentDetailIndex = -1; document.removeEventListener('keydown', viewerKeyHandler); // 移除引用弹窗层级修复样式 const zfix = document.getElementById('xdex-detail-quote-zfix'); if (zfix) zfix.remove(); const overlay = document.getElementById('xdex-image-viewer'); if (!overlay) return; const detail = overlay.querySelector('.xv-detail'); if (detail) detail.remove(); const grid = overlay.querySelector('.xv-grid-scroll'); if (grid) { grid.style.display = ''; setTimeout(() => { // 补渲染:详情页期间加载的新页面现在可以正确测量列高 if (ImageViewer._pendingGridRender) { ImageViewer._pendingGridRender = false; renderGridImages(); } // 补渲染:详情页期间加载的向上页面 if (ImageViewer._pendingUpwardPages.size > 0) { const pending = Array.from(ImageViewer._pendingUpwardPages).sort((a, b) => b - a); ImageViewer._pendingUpwardPages.clear(); for (const p of pending) prependGridImages(p); } // 滚动到当前查看图片的卡片位置 if (_returnToImageId) { const card = grid.querySelector('[data-img-id="' + _returnToImageId + '"]'); if (card) { card.scrollIntoView({ block: 'center', behavior: 'instant' }); // 临时高亮 2 秒,方便定位 card.style.boxShadow = '0 0 0 3px #00ffccb3, 0 0 8px #66ccff66, 0 0 16px #99ffff40'; card.style.transition = 'box-shadow 0.6s ease'; setTimeout(() => { card.style.boxShadow = ''; }, 2000); } else { grid.scrollTop = ImageViewer.gridScrollTop; } } else { grid.scrollTop = ImageViewer.gridScrollTop; } }, 0); } const header = overlay.querySelector('.xv-header'); if (header) header.style.display = 'flex'; } // ── 详情层:渲染 ── function renderDetailLayer() { const overlay = document.getElementById('xdex-image-viewer'); if (!overlay) return; const grid = overlay.querySelector('.xv-grid-scroll'); if (grid) grid.style.display = 'none'; const header = overlay.querySelector('.xv-header'); if (header) header.style.display = 'none'; let detail = overlay.querySelector('.xv-detail'); if (!detail) { detail = document.createElement('div'); detail.className = 'xv-detail'; detail.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;background:var(--background,#111);'; overlay.appendChild(detail); } const img = ImageViewer.gridImages[ImageViewer.currentDetailIndex]; if (!img) { closeDetailLayer(); return; } detail.innerHTML = ` <div style="display:flex;align-items:center;justify-content:space-between;padding:8px 16px;background:var(--card,#1a1a1a);border-bottom:1px solid var(--border,#333);flex-shrink:0;"> <button class="xv-back-btn" style="padding:4px 10px;background:var(--primary,#006666);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">返回瀑布流</button> <span style="color:var(--foreground,#ccc);font-size:13px;">${ImageViewer.currentDetailIndex + 1} / ${ImageViewer.gridImages.length} · No.${img.id} · 第${img.page}页 · 第${img.pageIdx}/${ImageViewer.gridImages.reduce((m, g) => g.page === img.page && g.pageIdx > m ? g.pageIdx : m, 0)}张</span> <button class="xv-close-btn2" style="padding:4px 10px;background:var(--destructive,#EE0000);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">退出</button> </div> <div style="display:flex;flex:1;overflow:hidden;"> <div style="flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;"> <button class="xv-prev-btn" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);width:36px;height:36px;background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:50%;cursor:pointer;font-size:18px;z-index:2;">◀</button> <img class="xv-main-img" src="${img.imgUrl}" style="max-width:90%;max-height:90%;object-fit:contain;transition:transform .15s;cursor:grab;" draggable="false"> <button class="xv-next-btn" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);width:36px;height:36px;background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:50%;cursor:pointer;font-size:18px;z-index:2;">▶</button> </div> <div class="h-threads-info" style="width:240px;border-left:1px solid var(--border,#333);padding:12px;overflow-y:auto;background:var(--card,#1a1a1a);flex-shrink:0;font-size:13px;line-height:1.6;"> ${img.title && img.title !== '无标题' ? '<span class="h-threads-info-title" style="font-weight:bold;margin-right:6px;">' + escapeHtml(img.title) + '</span>' : ''} ${img.name && img.name !== '无名氏' ? '<span class="h-threads-info-email" style="color:var(--primary,#6cf);margin-right:6px;">' + escapeHtml(img.name) + '</span>' : ''} <span class="h-threads-info-createdat" style="color:var(--muted-foreground,#888);font-size:12px;">${escapeHtml(img.now)}</span> <br> ${img.userHash ? '<span class="h-threads-info-uid" style="color:var(--foreground,#ccc);">ID:' + escapeHtml(img.userHash) + '</span>' : ''}${img.userHash && img.userHash === ImageViewer.poHash ? ' <span class="uk-text-primary uk-text-small">(PO主)</span>' : ''} <a href="https://www.nmbxd1.com/t/${ImageViewer.threadId}?r=${img.id}" class="h-threads-info-id" target="_blank" rel="noopener" style="margin-left:8px;color:var(--primary,#6cf);font-size:12px;">No.${img.id}</a> ${(img.contentRaw && img.contentRaw !== '分享图片') ? '<div class="h-threads-content h-threads-info" style="margin-top:8px;color:var(--foreground,#ddd);line-height:1.5;">' + img.contentRaw + '</div>' : ''} </div> </div> <div style="display:flex;align-items:center;justify-content:center;gap:6px;padding:8px;background:var(--card,#1a1a1a);border-top:1px solid var(--border,#333);flex-shrink:0;"> <button class="xv-rotate-left" style="padding:4px 8px;font-size:12px;">左旋</button> <button class="xv-rotate-right" style="padding:4px 8px;font-size:12px;">右旋</button> <button class="xv-zoom-in" style="padding:4px 8px;font-size:12px;">放大</button> <button class="xv-zoom-out" style="padding:4px 8px;font-size:12px;">缩小</button> <button class="xv-reset" style="padding:4px 8px;font-size:12px;">复位</button> </div> `; // 绑定事件 detail.querySelector('.xv-back-btn').addEventListener('click', closeDetailLayer); detail.querySelector('.xv-close-btn2').addEventListener('click', closeImageViewer); detail.querySelector('.xv-prev-btn').addEventListener('click', () => navigateDetail(-1)); detail.querySelector('.xv-next-btn').addEventListener('click', () => navigateDetail(1)); detail.querySelector('.xv-rotate-left').addEventListener('click', () => adjustRotation(-90)); detail.querySelector('.xv-rotate-right').addEventListener('click', () => adjustRotation(90)); detail.querySelector('.xv-zoom-in').addEventListener('click', () => adjustScale(0.2)); detail.querySelector('.xv-zoom-out').addEventListener('click', () => adjustScale(-0.2)); detail.querySelector('.xv-reset').addEventListener('click', resetTransform); // 拖拽平移图片 const mainImg = detail.querySelector('.xv-main-img'); let dragStartX = 0, dragStartY = 0, dragMoved = false; mainImg.addEventListener('mousedown', (e) => { e.preventDefault(); dragStartX = e.clientX; dragStartY = e.clientY; dragMoved = false; mainImg.style.cursor = 'grabbing'; const onMove = (ev) => { const dx = ev.clientX - dragStartX; const dy = ev.clientY - dragStartY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true; // scale 影响平移量的感知,除以 scale 让拖拽距离与视觉一致 ImageViewer.panX += (ev.clientX - dragStartX) / ImageViewer.scale; ImageViewer.panY += (ev.clientY - dragStartY) / ImageViewer.scale; dragStartX = ev.clientX; dragStartY = ev.clientY; applyTransform(); }; const onUp = () => { mainImg.style.cursor = 'grab'; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); // 没有拖拽 → 当作点击,左 30% 上一张,右 30% 下一张 if (!dragMoved) { const rect = mainImg.getBoundingClientRect(); const x = e.clientX - rect.left; if (x < rect.width * 0.3) navigateDetail(-1); else if (x > rect.width * 0.7) navigateDetail(1); } }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }); // 滚轮缩放 const imgContainer = detail.querySelector(".xv-main-img"); if (imgContainer) { imgContainer.addEventListener("wheel", (e) => { e.preventDefault(); adjustScale(e.deltaY < 0 ? 0.15 : -0.15); }, { passive: false }); } applyTransform(); // 提升引用弹窗层级(覆盖详情层 z-index:10001) if (!document.getElementById('xdex-detail-quote-zfix')) { const zfix = document.createElement('style'); zfix.id = 'xdex-detail-quote-zfix'; // 详情层(10001)与导出串弹窗(10001)之上:遮罩 10006、窗体/关闭钮 10007、原生框 20000 已够 zfix.textContent = '#h-ref-view{z-index:10007!important}.qp-overlay-quote{z-index:10006!important}.qp-close-all{z-index:10007!important}.qp-stack{z-index:10007!important}'; document.head.appendChild(zfix); } // 标记饼干 try { if (typeof markAllCookies === 'function') markAllCookies(getFilterConfig().markedGroups || [], detail); } catch (e) {} // 选择性增强:顺序很重要 // 1. 先链接化URL(纯文本 → <a> 标签) try { const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); if (cfg && cfg.enableAutoUrlLinkify && typeof runAutoUrlLinkify === 'function') runAutoUrlLinkify(detail); } catch (e) {} // 2. 再拓展引用格式(裸数字 → <font> 标签,必须在URL链接化之后,避免破坏URL结构) try { if (typeof extendQuote === 'function') extendQuote(detail); } catch (e) {} // 3. 最后挂引用弹窗处理器(必须在extendQuote之后,才能覆盖新创建的<font>标签) try { if (typeof initExtendedContent === 'function') { console.log('[xv-detail] calling initExtendedContent, fonts:', detail.querySelectorAll("font[color='#789922']").length); initExtendedContent(detail); console.log('[xv-detail] initExtendedContent done'); } } catch (e) { console.error('[xv-detail] initExtendedContent error:', e); } } function decodeHtmlEntities(text) { const ta = document.createElement('textarea'); ta.innerHTML = text || ''; return ta.value; } // ── 详情层:导航 ── function navigateDetail(delta) { const next = ImageViewer.currentDetailIndex + delta; // 向左超过数组开头 → 加载上一页并跳到其最后一张 if (next < 0) { if (ImageViewer.loading) return; const minLoaded = Math.min(...Array.from(ImageViewer.loadedPages)); if (minLoaded <= 1) { toast('已经到顶♂了……'); return; } ImageViewer.loading = true; const targetPage = minLoaded - 1; loadViewerPages(ImageViewer.threadId, targetPage, 1).then(async () => { await preloadThumbnailSizes(); let lastIdx = -1; for (let i = ImageViewer.gridImages.length - 1; i >= 0; i--) { if (ImageViewer.gridImages[i].page === targetPage) { lastIdx = i; break; } } if (lastIdx >= 0) { ImageViewer.currentDetailIndex = lastIdx; ImageViewer._currentDetailImageId = ImageViewer.gridImages[lastIdx]?.id; renderDetailLayer(); prefetchAdjacentImages(lastIdx); } if (lastIdx < 0) toast('已是第一页'); ImageViewer.loading = false; }); return; } // 向右超过数组末尾 → 加载下一页并跳到其第一张 if (next >= ImageViewer.gridImages.length) { if (ImageViewer.loading) return; const maxLoaded = Math.max(...Array.from(ImageViewer.loadedPages), 0); ImageViewer.loading = true; const targetPage = maxLoaded + 1; loadViewerPages(ImageViewer.threadId, targetPage, 1).then(async () => { await preloadThumbnailSizes(); const firstIdx = ImageViewer.gridImages.findIndex(g => g.page === targetPage); if (firstIdx >= 0) { ImageViewer._currentDetailImageId = ImageViewer.gridImages[firstIdx]?.id; ImageViewer.currentDetailIndex = firstIdx; renderDetailLayer(); prefetchAdjacentImages(firstIdx); } if (firstIdx < 0) toast('已经到底♂了……'); ImageViewer.loading = false; }); return; } // 正常导航 ImageViewer.currentDetailIndex = next; ImageViewer._currentDetailImageId = ImageViewer.gridImages[next]?.id; ImageViewer.rotation = 0; ImageViewer.scale = 1; ImageViewer.panX = 0; ImageViewer.panY = 0; renderDetailLayer(); prefetchAdjacentImages(next); // 预加载相邻页 if (next >= ImageViewer.gridImages.length - 3) { loadNextGridPages(); } else if (next <= 2) { loadPrevGridPages(); } } // ── 图片变换 ── function applyTransform() { const img = document.querySelector('.xv-main-img'); if (!img) return; img.style.transform = `rotate(${ImageViewer.rotation}deg) scale(${ImageViewer.scale}) translate(${ImageViewer.panX}px, ${ImageViewer.panY}px)`; } function adjustRotation(deg) { ImageViewer.rotation = (ImageViewer.rotation + deg) % 360; applyTransform(); } function adjustScale(delta) { ImageViewer.scale = Math.max(0.01, ImageViewer.scale + delta); applyTransform(); } function resetTransform() { ImageViewer.rotation = 0; ImageViewer.scale = 1; ImageViewer.panX = 0; ImageViewer.panY = 0; applyTransform(); } // ── 预加载相邻图片 ── function prefetchAdjacentImages(index) { [-1, 1].forEach(offset => { const target = ImageViewer.gridImages[index + offset]; if (!target) return; const img = new Image(); img.src = target.imgUrl; }); } // ── 键盘处理 ── function viewerKeyHandler(e) { if (!ImageViewer.active || ImageViewer.currentDetailIndex < 0) return; switch (e.key) { case 'ArrowLeft': e.preventDefault(); navigateDetail(-1); break; case 'ArrowRight': e.preventDefault(); navigateDetail(1); break; case 'ArrowUp': e.preventDefault(); ImageViewer.panY += 60; applyTransform(); break; case 'ArrowDown': e.preventDefault(); ImageViewer.panY -= 60; applyTransform(); break; case '[': adjustRotation(-90); break; case ']': adjustRotation(90); break; case '=': case '+': adjustScale(0.2); break; case '-': adjustScale(-0.2); break; case '0': resetTransform(); break; } } /* -------------------------------------------------- * tag 27. 关闭引用 * -------------------------------------------------- */ // 阻止URL中r=参数导致的自动引用插入(网站原生行为) // 开关:设置面板 → 关闭引用(disableAutoQuote),实时生效 // 方案 B:仍拦截自动插入,但用户手动点串号时短暂放行 let xdexManualQuoteBypass = null; // { rid: string|null, until: number } function allowManualQuoteInsert(rid, ttlMs = 2000) { xdexManualQuoteBypass = { rid: rid != null && rid !== '' ? String(rid) : null, until: Date.now() + (Number(ttlMs) > 0 ? Number(ttlMs) : 2000) }; } function shouldBypassAutoQuoteStrip(value, quoteRe, rParam) { const state = xdexManualQuoteBypass; if (!state) return false; if (Date.now() > state.until) { xdexManualQuoteBypass = null; return false; } // 未指定 rid:放行任何手动写入;指定了则仅当写入内容匹配对应引用时放行 if (!state.rid) return true; if (String(state.rid) === String(rParam || '') && quoteRe && quoteRe.test(String(value || ''))) return true; try { const esc = String(state.rid).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp('^\\s*>>No\\.' + esc + '\\s*\\n?').test(String(value || '')); } catch (e) { return false; } } function extractQuoteIdFromClickTarget(target) { if (!target || !target.closest) return ''; // 发言预览的占位编号不参与自动引用放行(避免 No.占位号 误设 2 秒手动引用标记) if (target.closest('.h-preview-box')) return ''; const a = target.closest('a.h-threads-info-id, a[href*="r="], a[href*="/t/"]'); if (!a) return ''; // 仅关注串号/引用链接,避免误伤普通导航 const href = a.getAttribute('href') || ''; try { const url = new URL(href, location.origin); const rid = url.searchParams.get('r'); if (rid) return String(rid); } catch (e) {} const textMatch = String(a.textContent || '').match(/No\.(\d{4,8})/i); if (textMatch) return textMatch[1]; const hrefMatch = href.match(/(?:[?&]r=|No\.)(\d{4,8})/i); return hrefMatch ? hrefMatch[1] : ''; } // ★ ?page=N&r=ID 链接定位:打开页面时滚动到 r= 对应回复并尽量在视野中居中。 // 独立于「关闭引用」开关(开关只控制剥引用,定位是浏览体验)。 // 判定用 data-threads-id(r= 号即回复号),不依赖站点的 selected 标记时序; // 图片/相对时间等增强会改变高度,定位后二次校准。 function initReplyParamScrollTo() { try { const params = new URLSearchParams(location.search); const rParam = params.get('r'); if (!rParam || params.get('page') === null || !/^\d{6,8}$/.test(rParam)) return; const scrollTarget = () => document.querySelector('[data-threads-id="' + rParam + '"]'); let attempts = 0; const tryScroll = () => { const el = scrollTarget(); if (el) { el.scrollIntoView({ block: 'center', behavior: 'auto' }); // 二次校准:图片/懒加载/字体就绪后高度变化会偏离居中 setTimeout(() => { try { if (el.isConnected) el.scrollIntoView({ block: 'center', behavior: 'auto' }); } catch (e) {} }, 800); return; } if (++attempts <= 20) setTimeout(tryScroll, 300); }; tryScroll(); } catch (e) { /* 定位失败不影响页面 */ } } function initDisableAutoQuote() { const rParam = new URLSearchParams(location.search).get('r'); if (!rParam) return; const rEsc = rParam.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const quoteRe = new RegExp('^\\s*>>No\\.' + rEsc + '\\s*\\n?'); // 读取实时设置(支持面板切换后即时生效) function isEnabled() { try { const s = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); return s.disableAutoQuote !== false; // 默认 true(开启拦截) } catch (e) { return true; } } // 值班室/举报表单需要保留 r= 自动引用,以便精准举报 function shouldPreserveAutoQuoteForReport() { try { const decodedPath = decodeURIComponent(location.pathname || ''); if (/^\/f\/值班室(?:\/|$)/.test(decodedPath)) return true; } catch (e) {} try { return !!document.querySelector('form[action="/Home/Forum/doReplyThread.html"] #h-report-switch, form[action="/Home/Forum/doPostThread.html"] #h-report-switch, #h-report-switch'); } catch (e) { return false; } } // 检查草稿是否本身以该引用号开头(如果是,说明引用来自草稿而非网站自动插入) function draftHasQuote() { try { if (typeof readDraftValue === 'function') { const draft = readDraftValue(); return typeof draft === 'string' && quoteRe.test(draft); } } catch (e) {} return false; } function shouldStripAutoQuote(v) { if (!isEnabled() || draftHasQuote() || shouldPreserveAutoQuoteForReport()) return false; if (!quoteRe.test(String(v || ''))) return false; // 用户手动点击/手动追记:短暂放行 if (shouldBypassAutoQuoteStrip(v, quoteRe, rParam)) return false; return true; } function interceptTextarea(ta) { if (ta.__quoteIntercepted) return; ta.__quoteIntercepted = true; const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value'); Object.defineProperty(ta, 'value', { configurable: true, enumerable: true, get() { return desc.get.call(this); }, set(v) { // 仅剥离 URL r= 导致的自动引用;手动点串号追记不拦 if (shouldStripAutoQuote(v)) { v = String(v).replace(quoteRe, ''); } desc.set.call(this, v); } }); // 初始清理(仅在开启且草稿不含该引用且非值班室/举报场景时) if (shouldStripAutoQuote(ta.value)) { const cleaned = String(ta.value).replace(quoteRe, ''); desc.set.call(ta, cleaned); ta.dispatchEvent(new Event('input', { bubbles: true })); } } // 捕获阶段监听点击:先于站点/自己的写入逻辑设好放行标记 if (!initDisableAutoQuote.__manualClickBound) { initDisableAutoQuote.__manualClickBound = true; document.addEventListener('click', (e) => { if (e.button != null && e.button !== 0) return; const rid = extractQuoteIdFromClickTarget(e.target); if (!rid) return; // 只需要放行与 URL r= 相同的那条(其他 No. 本来就不会被剥) if (String(rid) === String(rParam)) allowManualQuoteInsert(rid); }, true); } const obs = new MutationObserver(() => { const ta = document.querySelector('textarea.h-post-form-textarea'); if (ta) { interceptTextarea(ta); obs.disconnect(); } }); obs.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { const ta = document.querySelector('textarea.h-post-form-textarea'); if (ta) interceptTextarea(ta); }, 1000); } /* -------------------------------------------------- * tag 28. 值班室自动选择理由 * -------------------------------------------------- */ // 举报理由:自动补充并默认选择“其他”,避免值班室/举报表单未选理由时阻止发送 function autoSelectReportReason(root = document) { const scope = root && root.querySelectorAll ? root : document; const selects = []; if (scope.matches && scope.matches('select#h-report-switch')) selects.push(scope); scope.querySelectorAll?.('select#h-report-switch').forEach(sel => selects.push(sel)); selects.forEach(sel => { if (sel.__xdexReportReasonHandled) return; sel.__xdexReportReasonHandled = true; const customValue = '其他'; let option = Array.from(sel.options || []).find(opt => opt.value === customValue || opt.textContent.trim() === customValue); if (!option) { option = document.createElement('option'); option.value = customValue; option.textContent = customValue; sel.appendChild(option); } if (!sel.value || sel.value === '-1') { sel.value = option.value; sel.dispatchEvent(new Event('change', { bubbles: true })); } const form = sel.closest('form'); const info = (form || document).querySelector('#h-report-info'); if (info && sel.value && sel.value !== '-1') info.style.display = 'none'; }); if (!autoSelectReportReason.__observerInstalled && root === document && document.body) { autoSelectReportReason.__observerInstalled = true; const observer = new MutationObserver(mutations => { mutations.forEach(m => { m.addedNodes.forEach(node => { if (node && node.nodeType === 1) autoSelectReportReason(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } } /* -------------------------------------------------- * tag 29. WebDAV 备份/同步 * -------------------------------------------------- */ const WEBDAV_CONFIG_KEY = 'xdex_webdav_config'; const WEBDAV_SYNC_FILE = 'xdex-webdav-sync.json'; // 自动同步:跨页面共享计时器(localStorage),间隔 1 小时;到点未同步则新页面立即补一次;手动同步会重置计时器 const WEBDAV_AUTO_INTERVAL_MS = 60 * 60 * 1000; const WEBDAV_AUTO_NEXT_KEY = 'xdex_webdav_auto_next_ts'; const WEBDAV_AUTO_RUNNING_KEY = 'xdex_webdav_auto_running_ts'; const WEBDAV_AUTO_LAST_FP_KEY = 'xdex_webdav_last_fp'; const WEBDAV_AUTO_LAST_IMMEDIATE_KEY = 'xdex_webdav_last_immediate_ts'; // 设置冲突挂起:手动同步选择「取消本次同步」后置位;自动同步遇到差异时跳过设置部分,直到手动处理 const WEBDAV_SETTINGS_PENDING_KEY = 'xdex_webdav_settings_pending'; // 开启开关触发的立即同步:60s 冷却 + 内容指纹防重 const WEBDAV_IMMEDIATE_COOLDOWN_MS = 60 * 1000; function webdavAutoGet(key) { try { const v = localStorage.getItem(key); return v == null ? '' : String(v); } catch (e) { return ''; } } function webdavFnv1a(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = (h * 0x01000193) >>> 0; } return h; } // 内容指纹:剔除自变机制字段(lastSyncAt / lastUpdatedAt),保留反映真实活动的数据 function webdavFingerprint(payload) { try { const copy = JSON.parse(JSON.stringify(payload || {})); const strip = (v) => { if (v === null || typeof v !== 'object') return; Object.keys(v).forEach((k) => { if (k === 'lastSyncAt' || k === 'lastUpdatedAt') { delete v[k]; } else if (v[k] !== null && typeof v[k] === 'object') { strip(v[k]); } }); }; strip(copy); return String(webdavFnv1a(JSON.stringify(copy))); } catch (e) { return ''; } } function webdavAutoNow() { return Date.now(); } function webdavAutoRead(key) { try { return Number(localStorage.getItem(key)) || 0; } catch (e) { return 0; } } function webdavAutoWrite(key, ts) { try { localStorage.setItem(key, String(ts)); } catch (e) {} } function webdavAutoRemove(key) { try { localStorage.removeItem(key); } catch (e) {} } function getWebdavConfig() { try { const saved = typeof GM_getValue === 'function' ? GM_getValue(WEBDAV_CONFIG_KEY, {}) : {}; return Object.assign({ url: '', username: '', password: '', autoSync: false, lastSyncAt: 0 }, saved); } catch (e) { return { url: '', username: '', password: '', autoSync: false, lastSyncAt: 0 }; } } function storeWebdavConfig(cfg) { try { if (typeof GM_setValue === 'function') GM_setValue(WEBDAV_CONFIG_KEY, cfg); } catch (e) {} return cfg; } function ensureWebdavConfigInPanel() { const cfg = getWebdavConfig(); const $url = $('#sp_webdavUrl'); if (!$url.length) return; if (!$url.val()) $url.val(cfg.url); if (!$('#sp_webdavUsername').val()) $('#sp_webdavUsername').val(cfg.username); if (!$('#sp_webdavPassword').val()) $('#sp_webdavPassword').val(cfg.password); $('#sp_webdavAutoSync').prop('checked', cfg.autoSync); } function saveWebdavConfigFromPanel(showToast) { const prev = getWebdavConfig(); const cfg = { url: String($('#sp_webdavUrl').val() || '').trim(), username: String($('#sp_webdavUsername').val() || '').trim(), password: String($('#sp_webdavPassword').val() || ''), autoSync: !!$('#sp_webdavAutoSync').is(':checked'), lastSyncAt: prev.lastSyncAt || 0 }; storeWebdavConfig(cfg); if (showToast && typeof toast === 'function') toast('WebDAV 配置已保存'); // 刚打开自动同步开关(false→true):立即同步一次,并让计时器从此刻起算 // 防重:60s 冷却 + 内容指纹(无变化不写服务器) if (showToast && cfg.autoSync && !prev.autoSync && cfg.url) { // 开启自动同步(保存路径):立即同步一次(防重逻辑在 webdavTriggerImmediateSync 内) webdavTriggerImmediateSync(cfg); } } // 开启自动同步触发的立即同步:60s 冷却 + 内容指纹防重 + 互斥锁 function webdavTriggerImmediateSync(cfg) { const now = webdavAutoNow(); webdavAutoWrite(WEBDAV_AUTO_NEXT_KEY, now + WEBDAV_AUTO_INTERVAL_MS); // 冷却层:短时间连续开关不再每次触发 const lastImmediate = webdavAutoRead(WEBDAV_AUTO_LAST_IMMEDIATE_KEY); if (now - lastImmediate < WEBDAV_IMMEDIATE_COOLDOWN_MS) { webdavAutoWrite(WEBDAV_AUTO_LAST_IMMEDIATE_KEY, now); if (typeof toast === 'function') toast('WebDAV:刚同步过,已跳过本次立即同步'); return; } // 指纹层:本次要上传的内容与上次成功同步时一致则跳过 const utils = getWebdavUtils(); if (utils && typeof utils.buildFullExportFile === 'function') { const fp = webdavFingerprint(utils.buildFullExportFile(webdavFullSelection()).file.payload); if (fp && webdavAutoGet(WEBDAV_AUTO_LAST_FP_KEY) === fp) { webdavAutoWrite(WEBDAV_AUTO_LAST_IMMEDIATE_KEY, now); if (typeof toast === 'function') toast('WebDAV:内容无变化,跳过同步'); return; } } // 走互斥锁立即同步;成功后重算指纹(下载/上传后本地内容都可能变化) const running = webdavAutoRead(WEBDAV_AUTO_RUNNING_KEY); if (now - running < 30000) { if (typeof toast === 'function') toast('WebDAV:有同步正在进行,请稍后重试'); return; } webdavAutoWrite(WEBDAV_AUTO_RUNNING_KEY, now); webdavAutoWrite(WEBDAV_AUTO_LAST_IMMEDIATE_KEY, now); // 延迟 300ms,让「配置已保存/已开启」toast 先展示,再显示同步结果 setTimeout(() => { webdavSyncCore(cfg, false).then(() => { try { const u2 = getWebdavUtils(); if (u2 && typeof u2.buildFullExportFile === 'function') { webdavAutoWrite(WEBDAV_AUTO_LAST_FP_KEY, webdavFingerprint(u2.buildFullExportFile(webdavFullSelection()).file.payload)); } } catch (e) {} }).finally(() => webdavAutoRemove(WEBDAV_AUTO_RUNNING_KEY)); }, 300); } // WebDAV 请求全局限流:同步一次会连续发多个请求(读远端 / 上传前拉取 / PUT), // 部分 WebDAV 服务(或反向代理)对连续同连接请求会拒绝/挂起,表现为 // 「第 1 次成功、第 2 次超时、第 3 次 HTTP 0」。这里强制最小间隔,串行化发车。 const WEBDAV_MIN_REQUEST_GAP_MS = 400; let webdavLastRequestAt = 0; let webdavRequestChain = Promise.resolve(); function webdavThrottle() { const run = webdavRequestChain.then(() => { const wait = webdavLastRequestAt + WEBDAV_MIN_REQUEST_GAP_MS - Date.now(); return wait > 0 ? new Promise((r) => setTimeout(r, wait)) : Promise.resolve(); }).then(() => { webdavLastRequestAt = Date.now(); }); webdavRequestChain = run.catch(() => {}); return run; } function webdavRequest(details) { return new Promise((resolve) => { if (typeof GM_xmlhttpRequest !== 'function') { resolve({ ok: false, status: 0, unavailable: true }); return; } let settled = false; const settle = (result) => { if (settled) return; settled = true; resolve(result); }; const req = { method: details.method || 'GET', url: details.url, headers: details.headers || {}, data: details.body || null, responseType: details.responseType || 'text', timeout: details.timeout || 10000, onload: (resp) => settle({ ok: resp.status >= 200 && resp.status < 300, status: resp.status, statusText: resp.statusText || '', responseText: resp.responseText || '' }), onerror: (err) => { console.error('[webdav] 请求 onerror', { url: details.url, method: details.method || 'GET', name: err && err.name, message: err && err.message ? String(err.message) : '(空)', rawError: err }); settle({ ok: false, status: 0, networkError: true, errorMessage: err && err.message ? String(err.message) : '' }); }, ontimeout: () => settle({ ok: false, status: 0, timeout: true }) }; try { // 过限流闸门后再发车(闸门是 Promise,不阻塞本次 settle 逻辑) webdavThrottle().then(() => { try { GM_xmlhttpRequest(req); } catch (err) { console.error('[webdav] GM_xmlhttpRequest 调用异常', err); settle({ ok: false, status: 0, callError: err && err.message ? String(err.message) : String(err) }); } }); } catch (err) { console.error('[webdav] GM_xmlhttpRequest 调用异常', err); settle({ ok: false, status: 0, callError: err && err.message ? String(err.message) : String(err) }); } // 兜底:即使管理器的 onload/onerror/ontimeout 全部未触发,也避免 Promise 永久挂起 setTimeout(() => settle({ ok: false, status: 0, timeout: true, hanging: true }), (details.timeout || 10000) + 3000); }); } // 带重试的 WebDAV 请求:连续请求时偶发「超时 / HTTP 0」多为连接被复用或服务端瞬时拒绝, // 退避后重试一次通常即可成功。只重试网络层失败(status 0),不重试 4xx/5xx。 async function webdavRequestRetry(details, attempts) { const max = Math.max(1, attempts || 2); let last = null; for (let i = 0; i < max; i += 1) { last = await webdavRequest(details); const retriable = last && last.status === 0 && (last.timeout || last.hanging || last.networkError); if (!retriable) return last; if (i < max - 1) { const backoff = 800 * (i + 1); console.warn('[webdav] 请求失败,' + backoff + 'ms 后重试', { url: details.url, method: details.method, attempt: i + 1 }); await new Promise((r) => setTimeout(r, backoff)); } } return last; } function buildWebdavAuthHeader(cfg) { const raw = cfg.username + ':' + cfg.password; return 'Basic ' + btoa(unescape(encodeURIComponent(raw))); } function webdavBaseUrl(cfg) { return String(cfg.url || '').replace(/\/+$/, '') + '/'; } async function checkWebdavConnection(cfg) { const headers = { Authorization: buildWebdavAuthHeader(cfg) }; const url = webdavBaseUrl(cfg); const isPlainHttp = /^http:\/\//i.test(url); if (isPlainHttp) { // Firefox 扩展环境默认 CSP 带 upgrade-insecure-requests,http:// 可能被升级为 https:// 导致失败 console.warn('[webdav] HTTP 明文地址提示', { url, note: 'Firefox/Tampermonkey 可能拦截或升级明文 HTTP 请求,建议启用 HTTPS' }); } // 用 GET 探测:GET 是浏览器/脚本环境都支持的基础方法,PROPFIND 在部分脚本管理器不可用 console.log('[webdav] 检查连接请求', { method: 'GET', url }); const r = await webdavRequest({ url, method: 'GET', headers }); if (r.unavailable) return { ok: false, message: 'GM_xmlhttpRequest 不可用' }; if (r.callError) return { ok: false, message: 'GM_xmlhttpRequest 异常:' + r.callError }; if (r.hanging) return { ok: false, message: '请求无响应(脚本管理器未触发回调),请重试或检查脚本版本' }; if (r.networkError) { const msg = r.errorMessage || ''; // Tampermonkey 未授权域名时 onerror 会带 denied/connect 提示 if (/denied|not allowed|connect/i.test(msg)) { return { ok: false, message: '请求被脚本管理器拦截:请在 Tampermonkey/脚本管理器中授权该 WebDAV 域名(或将其加入脚本 @connect)' }; } const plainHttp = /^http:\/\//i.test(url); return { ok: false, message: '网络错误:无法连接到服务器' + (plainHttp ? '(注意:当前为 HTTP 明文地址,请确认服务器可达,若可建议改用 HTTPS)' : '') + (msg ? '(' + msg.slice(0, 160) + ')' : '') }; } if (r.timeout) return { ok: false, message: '连接超时' }; if (r.status === 401 || r.status === 403) return { ok: false, message: '认证失败(HTTP ' + r.status + '),请检查账户与密码' }; if (r.status === 0) return { ok: false, message: '请求失败' }; // 404/405/207 等均说明服务器可达且认证通过(目录无 index / 不支持 GET 均属正常) if ([200, 201, 204, 207, 301, 302, 404, 405].indexOf(r.status) >= 0) { // ★ 目录可达 ≠ 同步文件可读写:同步实际请求的是 base + WEBDAV_SYNC_FILE。 // 这里追加一次文件级探测,让「连接成功」真正覆盖同步路径。 // 预期结果:200(文件已存在)/ 404(尚未上传,属正常)/ 207 / 301 / 302 / 405 // 失败即返回具体原因,避免「测试连接成功但同步 HTTP 0」的困惑 const fileUrl = webdavBaseUrl(cfg) + WEBDAV_SYNC_FILE; // 文件探测用 HEAD:只需状态码(200 存在 / 404 不存在),不需要响应体。 // 某些 WebDAV 对「不存在的文件」的 GET 会长时间挂起(后端尝试生成/索引), // 用 HEAD 能显著降低这种风险;HEAD 不被支持时再退回 GET。 console.log('[webdav] 检查同步文件请求', { method: 'HEAD', url: fileUrl }); let rf = await webdavRequest({ url: fileUrl, method: 'HEAD', headers, timeout: 8000 }); if (rf.status === 405 || rf.status === 501) { console.log('[webdav] HEAD 不被支持,退回 GET', { status: rf.status }); rf = await webdavRequest({ url: fileUrl, method: 'GET', headers, timeout: 8000 }); } if (rf.unavailable) return { ok: false, message: '同步文件探测:GM_xmlhttpRequest 不可用' }; if (rf.callError) return { ok: false, message: '同步文件探测异常:' + rf.callError }; if (rf.hanging) { return { ok: false, message: '同步文件探测无响应:服务器对 ' + WEBDAV_SYNC_FILE + ' 的请求在 8s 内没有返回。' + '目录本身可达,说明地址与认证没问题;请确认该 WebDAV 服务允许读取/创建文件(部分服务对不存在的文件会长时间挂起)' }; } if (rf.networkError) { const fmsg = rf.errorMessage || ''; if (/denied|not allowed|connect/i.test(fmsg)) { return { ok: false, message: '同步文件被脚本管理器拦截:请授权该 WebDAV 域名(或加入 @connect)' + (fmsg ? '(' + fmsg.slice(0, 120) + ')' : '') }; } return { ok: false, message: '同步文件网络错误:无法访问 ' + WEBDAV_SYNC_FILE + (fmsg ? '(' + fmsg.slice(0, 160) + ')' : '') }; } if (rf.timeout) return { ok: false, message: '同步文件探测超时(目录可达,但文件请求超时)' }; if (rf.status === 401 || rf.status === 403) return { ok: false, message: '同步文件认证失败(HTTP ' + rf.status + ')' }; if (rf.status === 0) return { ok: false, message: '同步文件请求失败(HTTP 0):' + (rf.timeout ? '超时' : '网络层错误,请查看控制台 [webdav] 日志') }; if ([200, 201, 204, 207, 301, 302, 404, 405].indexOf(rf.status) >= 0) { return { ok: true, message: '连接成功(目录 HTTP ' + r.status + ',同步文件 HTTP ' + rf.status + ')' }; } return { ok: false, message: '同步文件异常响应 HTTP ' + rf.status + (rf.statusText ? ' ' + rf.statusText : '') }; } return { ok: false, message: '异常响应 HTTP ' + r.status + (r.statusText ? ' ' + r.statusText : '') }; } function setWebdavStatus(text) { const $el = $('#sp_webdavStatus'); if ($el && $el.length) $el.text(text); } function webdavPanelField(name) { // 模板渲染时读取已保存配置,让输入框默认显示(无需先点击) try { const cfg = getWebdavConfig(); if (name === 'url') return cfg.url || ''; if (name === 'username') return cfg.username || ''; if (name === 'password') return cfg.password || ''; if (name === 'autoSync') return cfg.autoSync ? 'checked' : ''; if (name === 'lastSync') return webdavFormatLastSync(cfg.lastSyncAt || 0); } catch (e) {} return ''; } function webdavFormatLastSync(ts) { if (!ts) return '尚未同步'; return '上次同步 ' + webdavFormatTime(ts); } function webdavFormatTime(ts) { try { const d = new Date(ts); const pad = (n) => String(n).padStart(2, '0'); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } catch (e) { return '?'; } } function webdavUpdateLastSyncLabel() { const $el = $('#sp_webdavLastSyncLabel'); if ($el && $el.length) $el.text(webdavFormatLastSync((getWebdavConfig().lastSyncAt) || 0)); } // 与 tag1 FIXED_KEYS 中的固定启用项对齐:这些键不参与「本地是否默认」与差异提示,避免噪声 const WEBDAV_SETTINGS_SKIP_KEYS = new Set([ 'enableImageHideMode', 'interceptReplyForm', 'updateReplyNumbers', 'replaceRightSidebar', 'enablePostExpandAll', 'kaomojiEnhancer', 'enableImageViewerMode', 'autoSelectReportReason', 'enableFavoriteThreads', 'enableThreadHistory', 'enablePostHistory', // 分组顺序元数据:走静默合并(at 新者胜),不参与差异弹窗判定 'groupOrderMeta', 'enableSubscriptionFeed', // 默认订阅号:按 updatedAt 静默合并(新者胜),不参与差异弹窗判定 'defaultSubscriptionFeedUuid', 'defaultSubscriptionFeedUpdatedAt' ]); function sameSettingValue(a, b) { if (a === b) return true; if (a == null || b == null) return a === b; if (typeof a !== typeof b) return false; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!sameSettingValue(a[i], b[i])) return false; } return true; } if (typeof a === 'object') { const ak = Object.keys(a).sort(); const bk = Object.keys(b).sort(); if (ak.length !== bk.length) return false; for (let i = 0; i < ak.length; i++) { if (ak[i] !== bk[i]) return false; if (!sameSettingValue(a[ak[i]], b[bk[i]])) return false; } return true; } return false; } // 设置键 → 中文短名称(用于 WebDAV 冲突弹窗等需要简明标识的场景;与 spDescriptions 的键对应) const SETTING_LABELS = { enableCookieSwitch: '饼干切换器', enableCookieConfirm: '发送前饼干确认', disableWatermark: '取消默认水印', enablePaginationDuplication: '添加页首页码', updatePreviewCookie: '预览框显示真实饼干', hideEmptyTitleEmail: '隐藏空标题/无名氏', enableExternalImagePreview: '外部图床预览', enableAutoCookieRefresh: '自动刷新饼干', enableAutoCookieRefreshToast: '自动刷新提示', interceptReplyFormUnvcode: 'unvcode替换模式', interceptReplyFormU200B: '零宽空格模式', interceptReplyFormAutoCompress: '自动压缩图片', enableSeamlessPaging: '无缝翻页', enableAutoSeamlessPaging: '自动无缝翻页', enableHDImageAndLayoutFix: '原图加载与布局修复', enableLinkBlank: '链接新标签页打开', enableAutoUrlLinkify: '网址自动转链接', enableQuotePreview: '优化引用弹窗', enableUpdateCheck: '自动检查更新', enableImageContextMenu: '图片右键菜单', enableImageHideMode: '图片显示模式', applyImageHideMode: '图片隐藏模式应用', imageViewerSeparators: '阅图模式分隔线', enableDraft: '草稿功能', enableThreadHistory: '浏览历史', enablePostHistory: '发言历史', timeDisplayMode: '时间显示方式', extendQuote: '拓展引用格式', extendQuoteAvailabilityDetection: '引用号检测', enablePostExpandAll: '展开/收起长串', kaomojiSort: '颜文字排序', toggleSidebar: '自动收起侧边栏', enableCustomDarkTheme: '深色模式', dockDisplayMode: '扩展坞显示模式', postAfterAction: '发串后行为', disableAutoQuote: '禁用自动引用', threadCookieWhitelistGroups: '只看饼干分组', threadCookieWhitelistDisplayMode: '只看饼干显示模式', poAnnotationSideDisplayMode: '批注侧栏显示模式', replyModeDefault: '默认回复模式', replyExtraDefault: '默认回复附加项', markedGroups: '标记饼干分组', blockedCookies: '屏蔽饼干分组', blockedKeywords: '屏蔽关键词分组', favoriteThreads: '常用串分组', subscriptionFeeds: '订阅号分组', defaultSubscriptionFeedUuid: '默认订阅号', defaultSubscriptionFeedUpdatedAt: '默认订阅号更新时间', groupOrderMeta: '分组排序', blockDisplayMode: '屏蔽显示方式', }; // 取设置项中文名(未知键回退为原键名) function getSettingLabel(key) { return SETTING_LABELS[key] || key; } function webdavShowSettingsConflictDialog(diffInfo) { // 三选:采用远端 / 保留本地上传 / 取消本次同步(Promise 化) return new Promise((resolve) => { const backdrop = document.createElement('div'); backdrop.style.cssText = 'position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;'; const panel = document.createElement('div'); // 使用设置面板的 dark reader 兼容变量 panel.style.cssText = 'background:var(--xdex-sp-panel-bg, #FFFFEE);color:var(--foreground, #333);border:1px solid var(--xdex-sp-border, #ccc);border-radius:8px;box-shadow:0 8px 24px var(--xdex-sp-shadow, rgba(0,0,0,.3));padding:16px 18px;max-width:min(460px,92vw);font-size:13px;line-height:1.6;'; // 中文名 + 加粗醒目色(多个用顿号分隔;超过 6 个折叠为省略号) const labelOf = (k) => { const name = getSettingLabel(k); const changed = name !== k; // 未收录的键保持原样(不加醒目色) return changed ? `<b style="color:var(--xdex-conflict-key, #C0392B);font-weight:700;">${name}</b>` : `<b style="font-weight:700;">${name}</b>`; }; const shown = diffInfo.diff.slice(0, 6).map(labelOf).join('、'); const sample = shown + (diffInfo.diff.length > 6 ? '…' : ''); panel.innerHTML = '' + '<div style="font-size:15px;font-weight:700;margin-bottom:8px;">检测到设置与远端不同</div>' + '<div style="margin-bottom:4px;">共 ' + diffInfo.diff.length + ' 项:' + sample + (diffInfo.localIsDefault ? '(本地当前为默认设置)' : '') + '</div>' + '<div style="color:var(--muted-foreground, #666);margin-bottom:12px;">请选择保留哪个版本的设置:</div>' + '<div style="display:flex;flex-direction:column;gap:8px;">' + '<button data-act="remote" style="padding:6px 10px;cursor:pointer;background:var(--xdex-sp-fold-bg, #F0E0D6);border:1px solid var(--xdex-sp-border, #ccc);border-radius:6px;color:var(--foreground, #333);">采用远端设置(覆盖本地)</button>' + '<button data-act="local" style="padding:6px 10px;cursor:pointer;background:var(--xdex-sp-fold-bg, #F0E0D6);border:1px solid var(--xdex-sp-border, #ccc);border-radius:6px;color:var(--foreground, #333);">保留本地设置(上传覆盖远端)</button>' + '<button data-act="cancel" style="padding:6px 10px;cursor:pointer;background:var(--xdex-sp-fold-bg, #F0E0D6);border:1px solid var(--xdex-sp-border, #ccc);border-radius:6px;color:var(--foreground, #333);">取消本次同步</button>' + '</div>'; backdrop.appendChild(panel); document.body.appendChild(backdrop); const done = (act) => { try { document.body.removeChild(backdrop); } catch (e) {} document.removeEventListener('keydown', onKey, true); resolve(act); }; const onKey = (ev) => { if (ev.key === 'Escape') done('cancel'); }; panel.addEventListener('click', (e) => { const btn = e.target && e.target.closest ? e.target.closest('button[data-act]') : null; if (btn) { e.stopPropagation(); done(btn.getAttribute('data-act')); } }); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) done('cancel'); }); document.addEventListener('keydown', onKey, true); }); } // ── 默认订阅号跨端合并(时间戳新者胜)── // 规则: // - 两端都从未设置过(无时间戳且无值)→ 保持空 // - 一端从未设置过(updatedAt=0)→ 另一端的值直接生效(若另一方明确清空也照此处理) // - 两端都有时间戳 → 新者胜;清空同样带时间戳,因此“清空”可以覆盖旧的默认 function mergeDefaultSubscriptionFeedSetting(localSettings, remoteSettings) { const l = localSettings || {}; const r = remoteSettings || {}; const lUuid = String(l.defaultSubscriptionFeedUuid || '').trim(); const rUuid = String(r.defaultSubscriptionFeedUuid || '').trim(); const lAt = Math.max(0, Number(l.defaultSubscriptionFeedUpdatedAt) || 0); const rAt = Math.max(0, Number(r.defaultSubscriptionFeedUpdatedAt) || 0); if (!lAt && !rAt) return { uuid: rUuid || lUuid, updatedAt: 0 }; if (!lAt) return { uuid: rUuid, updatedAt: rAt }; if (!rAt) return { uuid: lUuid, updatedAt: lAt }; return rAt > lAt ? { uuid: rUuid, updatedAt: rAt } : { uuid: lUuid, updatedAt: lAt }; } // 把远端默认订阅号按时间戳并入本地设置。 // 独立于“设置差异三选”决策:默认订阅号始终静默按时间戳合并,不参与冲突弹窗。 function applyRemoteDefaultSubscriptionFeed(remoteSettings) { if (!remoteSettings || typeof remoteSettings !== 'object') return false; if (!('defaultSubscriptionFeedUuid' in remoteSettings) && !('defaultSubscriptionFeedUpdatedAt' in remoteSettings)) return false; try { const local = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); const winner = mergeDefaultSubscriptionFeedSetting(local, remoteSettings); const localUuid = String(local.defaultSubscriptionFeedUuid || '').trim(); const localAt = Math.max(0, Number(local.defaultSubscriptionFeedUpdatedAt) || 0); if (winner.uuid === localUuid && winner.updatedAt === localAt) return false; const next = Object.assign({}, local, { defaultSubscriptionFeedUuid: winner.uuid, defaultSubscriptionFeedUpdatedAt: winner.updatedAt }); GM_setValue(SettingPanel.key, next); if (typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state) { SettingPanel.state.defaultSubscriptionFeedUuid = winner.uuid; SettingPanel.state.defaultSubscriptionFeedUpdatedAt = winner.updatedAt; try { if (typeof SettingPanel.syncInputs === 'function') SettingPanel.syncInputs(); } catch (e) {} } console.log('[webdav] 默认订阅号按时间戳合并', { uuid: winner.uuid, updatedAt: winner.updatedAt }); return true; } catch (e) { console.warn('[webdav] 合并默认订阅号失败', e); return false; } } // 标记饼干自定义配色:独立于“设置差异三选”,始终按配色规则静默合并(与默认订阅号同思路)。 // 只更新已存在分组的 color/colorAt,不在此处增删分组或改动饼干列表。 function applyRemoteMarkedGroupColors(remoteSettings) { if (!remoteSettings || typeof remoteSettings !== 'object' || !Array.isArray(remoteSettings.markedGroups)) return false; try { const local = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); const localGroups = normalizeMarkedGroups(local.markedGroups); const remoteGroups = normalizeMarkedGroups(remoteSettings.markedGroups); if (!localGroups.length || !remoteGroups.length) return false; let changed = false; const nextGroups = localGroups.map((localGroup) => { const remoteGroup = remoteGroups.find((g) => String((g && g.desc) || '') === localGroup.desc); if (!remoteGroup) return localGroup; const resolved = resolveMarkedGroupColorState(localGroup, remoteGroup); if (!resolved) return localGroup; if (resolved.color === localGroup.color && resolved.colorAt === localGroup.colorAt) return localGroup; changed = true; return Object.assign({}, localGroup, { color: resolved.color, colorAt: resolved.colorAt }); }); if (!changed) return false; const next = Object.assign({}, local, { markedGroups: nextGroups }); GM_setValue(SettingPanel.key, next); if (typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state) { SettingPanel.state.markedGroups = nextGroups; try { if (typeof SettingPanel.syncInputs === 'function') SettingPanel.syncInputs(); } catch (e) {} try { if (typeof refreshFilterDisplay === 'function') refreshFilterDisplay(SettingPanel.state); } catch (e) {} } console.log('[webdav] 标记配色按规则合并', nextGroups.filter((g) => g.colorAt > 0).map((g) => ({ desc: g.desc, color: g.color, colorAt: g.colorAt }))); return true; } catch (e) { console.warn('[webdav] 合并标记配色失败', e); return false; } } // 差异检测时忽略标记分组的纯配色差异:配色走独立的时间戳静默合并,不应触发设置冲突三选 function sameMarkedGroupsIgnoringColors(a, b) { const strip = (arr) => (Array.isArray(arr) ? arr : []).map((group) => { if (!group || typeof group !== 'object' || Array.isArray(group)) return group; const copy = Object.assign({}, group); delete copy.color; delete copy.colorAt; return copy; }); return sameSettingValue(strip(a), strip(b)); } function findWebdavSettingsDiff(remoteSettings) { // 返回 { diff: 两端不同的设置键, localIsDefault: 本地是否仍是默认值 } const result = { diff: [], localIsDefault: true }; try { const defaults = SettingPanel && SettingPanel.defaults ? SettingPanel.defaults : {}; const local = Object.assign({}, defaults, GM_getValue(SettingPanel.key, {})); if (!remoteSettings || typeof remoteSettings !== 'object') return result; Object.keys(defaults).forEach((k) => { if (WEBDAV_SETTINGS_SKIP_KEYS.has(k)) return; if (!sameSettingValue(local[k], defaults[k])) result.localIsDefault = false; const same = k === 'markedGroups' ? sameMarkedGroupsIgnoringColors : sameSettingValue; if (!(k in remoteSettings) || !same(local[k], remoteSettings[k])) result.diff.push(k); }); // 远端比 defaults 多的键(未来版本新增)也视为差异 Object.keys(remoteSettings).forEach((k) => { if (!(k in defaults) && !WEBDAV_SETTINGS_SKIP_KEYS.has(k)) result.diff.push(k); }); } catch (e) {} return result; } function webdavFullSelection() { // 同步文件不含 WebDAV 配置(用户自行选择同步源更合理);手动导入导出才迁移配置 return { settings: true, threadHistory: true, postHistory: true, drafts: false, kaomojiStats: true, cookiePrefs: true }; } function getWebdavUtils() { // full-export 工具定义在 SettingPanel.render 嵌套作用域,需先打开过一次设置面板(按钮本身在面板内) return window.__xdexWebdavUtils || null; } async function webdavUploadLocal(cfg, headers) { const utils = getWebdavUtils(); if (!utils || typeof utils.buildFullExportFile !== 'function') { return { ok: false, status: 0, missingUtils: true }; } const built = utils.buildFullExportFile(webdavFullSelection()); let historyMerged = false; // 本次上传是否经过“基线增量”合并;裸上传需另行推进基线 // 上传前先拉取远端(若存在):浏览历史按“基线增量”合并、发言历史按 postId/指纹归一化合并, // 避免全量覆盖其他端已贡献的独立记录 let existing = null; try { existing = await webdavRequestRetry({ url: webdavBaseUrl(cfg) + WEBDAV_SYNC_FILE, method: 'GET', headers, timeout: 15000 }); } catch (e) {} if (existing && existing.status >= 200 && existing.status < 300 && built.file.payload) { try { // webdavRequest 返回 GM 风格响应:正文在 responseText(无 .text() 方法) const existingText = String(existing.responseText || ''); const parsed = utils.parseFullExportFile(existingText); if (parsed.valid && parsed.data.payload) { if (built.file.payload.threadHistory && utils.mergeThreadHistoryStoreWebdav && parsed.data.payload.threadHistory) { const baselines = utils.getWebdavHistoryBaselines ? utils.getWebdavHistoryBaselines() : {}; const merged = utils.mergeThreadHistoryStoreWebdav( built.file.payload.threadHistory, parsed.data.payload.threadHistory, baselines ); built.file.payload.threadHistory = merged; // 回写本地:upload 端也必须吸收远端贡献。否则 baseline 已推进到合并值而本地滞后, // 下轮 delta = max(0, 本地旧值 − 基线) 归零,本端新增计数会被静默吞掉 setThreadHistoryStore(merged); if (utils.saveWebdavHistoryBaselinesFromStore) utils.saveWebdavHistoryBaselinesFromStore(merged, baselines); historyMerged = true; } if (built.file.payload.postHistory && utils.mergePostHistoryStore && parsed.data.payload.postHistory) { // local=本端待上传数据,imported=远端数据(归一化合并,保留本端 key 优先) const mergedPosts = utils.mergePostHistoryStore(built.file.payload.postHistory, parsed.data.payload.postHistory); built.file.payload.postHistory = mergedPosts; // 回写本地:upload 端同样应看到远端发言,否则需等到下次 download 才能补齐 setPostHistoryStore(mergedPosts); } if (built.file.payload.myScriptSettings && utils.mergeSettingsForWebdavUpload && parsed.data.payload.myScriptSettings) { // 标量项以本地为准,复杂项(分组类)双向合并后再上传 built.file.payload.myScriptSettings = utils.mergeSettingsForWebdavUpload(built.file.payload.myScriptSettings, parsed.data.payload.myScriptSettings); } } else { console.warn('[webdav] 上传前合并:远端文件解析失败,将原样覆盖上传', { error: parsed && parsed.error }); } console.log('[webdav] 上传内容统计', { threadHistoryCount: Object.keys((built.file.payload.threadHistory || {}).items || {}).length, postHistoryCount: Object.keys((built.file.payload.postHistory || {}).items || {}).length, remoteExisted: !!(parsed && parsed.valid) }); } catch (e) { console.warn('[webdav] 上传前合并历史失败,按原样上传', e); } } const put = await webdavRequestRetry({ url: webdavBaseUrl(cfg) + WEBDAV_SYNC_FILE, method: 'PUT', headers, body: JSON.stringify(built.file), // 全量导出体积较大,放宽 PUT 超时(默认 10s 对慢速上行易超时) timeout: 30000 }); const putOk = put.status >= 200 && put.status < 300; // 裸上传(远端缺失或内容无效,未经上面的“基线增量”合并)成功后必须把基线推进到本次上传值: // 否则该端基线停留在缺失/旧值,下一轮双向合并会把整份本地计数当成新增再加一遍(多端倍增) if (putOk && !historyMerged && built.file.payload && built.file.payload.threadHistory && typeof utils.saveWebdavHistoryBaselinesFromStore === 'function') { try { utils.saveWebdavHistoryBaselinesFromStore( built.file.payload.threadHistory, typeof utils.getWebdavHistoryBaselines === 'function' ? utils.getWebdavHistoryBaselines() : {} ); } catch (e) { console.warn('[webdav] 裸上传后推进基线失败', e); } } return { ok: putOk, status: put.status }; } async function webdavSyncCore(cfg, silent) { const notify = (t) => { if (!silent && typeof toast === 'function') toast(t); }; setWebdavStatus('同步中……'); // 不再单独调用 checkWebdavConnection:它本身要发 2 个请求(目录 + 文件), // 而紧接着的 GET 远端已经能覆盖「可达性 + 认证 + 文件状态」,省下这 2 个请求可显著 // 降低连续请求造成的服务端压力(此前一次同步最多 5 个请求紧挨着发)。 const headers = { Authorization: buildWebdavAuthHeader(cfg) }; const remoteUrl = webdavBaseUrl(cfg) + WEBDAV_SYNC_FILE; const remote = await webdavRequestRetry({ url: remoteUrl, method: 'GET', headers, timeout: 15000 }); if (remote.status === 404) { // 远端无数据:上传本地 const up = await webdavUploadLocal(cfg, headers); if (up.ok) { const now = Date.now(); storeWebdavConfig(Object.assign({}, cfg, { lastSyncAt: now })); webdavUpdateLastSyncLabel(); console.log('[webdav] 同步成功(远端无数据上传)', { direction: 'upload', lastSyncAt: now }); setWebdavStatus('远端无数据,本地数据已上传'); notify('WebDAV:本地数据已上传'); return { ok: true, direction: 'upload' }; } console.warn('[webdav] 上传失败', { direction: 'upload-first', status: up.status }); setWebdavStatus('上传失败(HTTP ' + up.status + ')'); notify('WebDAV 上传失败:HTTP ' + up.status); return { ok: false, reason: 'upload-failed' }; } if (remote.status !== 200) { // 区分 status:0 的成因(超时/网络层错误/管理器未回调),给出可操作的提示 let reason = ''; if (remote.status === 0) { if (remote.timeout) reason = remote.hanging ? '请求无响应(脚本管理器未触发回调)' : '请求超时'; else if (remote.networkError) { const m = remote.errorMessage || ''; reason = /denied|not allowed|connect/i.test(m) ? '请求被脚本管理器拦截(请授权域名)' : ('网络错误' + (m ? ':' + m.slice(0, 120) : '')); } else if (remote.callError) reason = '调用异常:' + remote.callError; else reason = '网络层错误(详见控制台 [webdav] 日志)'; } const detail = remote.status === 0 ? '(HTTP 0:' + reason + ')' : '(HTTP ' + remote.status + ')'; setWebdavStatus('读取远端失败' + detail); notify('WebDAV 读取远端失败' + detail); return { ok: false, reason: 'remote-read' }; } // 远端存在:按导出时间 last-write-wins let remoteExportedAt = 0; try { const head = JSON.parse(remote.responseText); remoteExportedAt = head && head.meta && head.meta.exportedAt ? Date.parse(head.meta.exportedAt) : 0; } catch (e) {} console.log('[webdav] 同步开始:双向合并模式', { remoteExportedAt: remoteExportedAt || null, lastSyncAt: cfg.lastSyncAt || 0 }); const utils = getWebdavUtils(); if (!utils || typeof utils.parseFullExportFile !== 'function' || typeof utils.applyFullImportPayload !== 'function') { setWebdavStatus('恢复功能未就绪:请先打开一次设置面板后重试'); if (!silent) notify('WebDAV:恢复功能未就绪,请先打开设置面板'); return { ok: false, reason: 'utils-missing' }; } const parsed = utils.parseFullExportFile(remote.responseText); if (!parsed || !parsed.valid) { // 远端缺失或内容非同步文件(部分 WebDAV 实现对不存在路径返回 207/HTML):无可合并内容, // 退化为纯上传以重建云端;upload 内部对既有远端内容的合并守卫会自行跳过无效数据 console.warn('[webdav] 远端无有效同步文件,转为本地上传', { status: remote.status, error: parsed && parsed.error }); const rebuild = await webdavUploadLocal(cfg, headers); const rebuiltAt = Date.now(); if (rebuild.ok) { storeWebdavConfig(Object.assign({}, cfg, { lastSyncAt: rebuiltAt })); webdavUpdateLastSyncLabel(); setWebdavStatus('远端无有效同步文件,已上传本地数据'); notify('WebDAV:远端无有效同步文件,已上传本地数据'); return { ok: true, direction: 'upload-rebuild' }; } setWebdavStatus('上传失败(HTTP ' + (rebuild.status == null ? '未知' : rebuild.status) + ')'); notify('WebDAV 上传失败:HTTP ' + rebuild.status); return { ok: false, reason: 'upload-failed' }; } // WebDAV 配置不随同步覆盖:同步上传端已排除; // 若用户手动导出的含 webdav 配置文件被放到远端,下载时过滤掉,避免远端反向改写本地同步源 if (parsed.data.payload && parsed.data.payload.webdavConfig) delete parsed.data.payload.webdavConfig; // 默认订阅号:独立于下面的“设置差异三选”,始终按 updatedAt 静默合并(新者胜) // —— 即使设置整体选择“保留本地”,默认订阅号也应按时间戳更新,避免两端长期分叉 try { applyRemoteDefaultSubscriptionFeed(parsed.data.payload && parsed.data.payload.myScriptSettings); } catch (e) { console.warn('[webdav] 默认订阅号合并失败', e); } // 设置差异检测:手动同步弹窗让用户选择;自动同步按「本地是否默认」决定 // 决策仅作用于 myScriptSettings 分区,其余数据分区一律双向合并 let settingsDecision = 'download'; let settingsSkipped = false; if (parsed.data.payload && parsed.data.payload.myScriptSettings) { const diffInfo = findWebdavSettingsDiff(parsed.data.payload.myScriptSettings); if (diffInfo.diff.length > 0) { if (silent) { // 自动同步:若此前手动取消过设置冲突(挂起标记),跳过设置部分,其余数据照常合并; // 否则按「本地是否默认」自动决策:默认→采用远端,已个性化→保留本地上传 if (webdavAutoGet(WEBDAV_SETTINGS_PENDING_KEY) === '1') { settingsDecision = 'keep-local'; } else { settingsDecision = diffInfo.localIsDefault ? 'download' : 'keep-local'; } } else { // 三选对话框:采用远端 / 保留本地上传 / 取消本次同步 const userChoice = await webdavShowSettingsConflictDialog(diffInfo); if (userChoice === 'cancel') { webdavAutoWrite(WEBDAV_SETTINGS_PENDING_KEY, '1'); setWebdavStatus('已取消本次同步(设置存在差异,自动同步将跳过设置直到手动处理)'); if (!silent) notify('WebDAV:已取消本次同步'); return { ok: false, reason: 'canceled' }; } webdavAutoRemove(WEBDAV_SETTINGS_PENDING_KEY); settingsDecision = userChoice === 'remote' ? 'download' : 'keep-local'; } } else { // 两端设置已一致:清除挂起标记 webdavAutoRemove(WEBDAV_SETTINGS_PENDING_KEY); } } // 标记饼干配色:独立于设置三选,按“默认色让步 / 手动色比时间戳”规则静默合并。 // 放在差异判定之后:避免刚并入的远端配色把 localIsDefault 从 true 改写成 false, // 从而错误地影响其它设置的自动同步方向。 try { applyRemoteMarkedGroupColors(parsed.data.payload && parsed.data.payload.myScriptSettings); } catch (e) { console.warn('[webdav] 标记配色合并失败', e); } if (settingsDecision !== 'download') { // 保留本地设置:仅剔除设置字段,其余数据分区照常双向合并 if (parsed.data.payload && parsed.data.payload.myScriptSettings) delete parsed.data.payload.myScriptSettings; settingsSkipped = true; } // ── 数据分区:远端 → 本地 合并 ── // 快照浏览历史存储与基线:回推失败时整体回滚,保证下轮 delta 仍可计算(否则本轮增量会被基线吞掉) const thStoreBeforeMerge = (typeof GM_getValue === 'function') ? GM_getValue(THREAD_HISTORY_STORAGE_KEY, null) : null; const thBaselineBeforeMerge = (typeof utils.getWebdavHistoryBaselines === 'function') ? (utils.getWebdavHistoryBaselines() || {}) : {}; const kaoStoreBeforeMerge = (typeof GM_getValue === 'function') ? GM_getValue('kaomojiUsageStats', null) : null; const report = utils.applyFullImportPayload(parsed.data, { threadHistoryMode: 'webdav-delta' }); // 远端设置被采用后:立即同步内存 state、回显面板,并即时应用可即时生效的设置 if (report.settings && settingsDecision === 'download' && typeof SettingPanel !== 'undefined' && SettingPanel && SettingPanel.state) { try { SettingPanel.state = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); if (typeof SettingPanel.syncInputs === 'function') SettingPanel.syncInputs(); if (typeof SettingPanel.syncAuxiliaryControls === 'function') SettingPanel.syncAuxiliaryControls(); try { if (typeof renderFavoriteThreadsMenu === 'function') renderFavoriteThreadsMenu(); } catch (e) {} try { if (typeof refreshFilterDisplay === 'function') refreshFilterDisplay(SettingPanel.state); } catch (e) {} try { if (typeof window.__xdexApplyTimeDisplayMode === 'function') window.__xdexApplyTimeDisplayMode(document); } catch (e) {} try { if (typeof applyImageHideMode === 'function') applyImageHideMode(SettingPanel.state.applyImageHideMode || 'default', document); } catch (e) {} } catch (e) { console.warn('[webdav] 远端设置即时应用失败', e); } } // ── 合并后的本地 → 云端 回推 ── // apply 阶段已把远端贡献并入本地、基线已对齐到合并值;此处必须【直传】而不能再走 merge: // 若再次按基线合并,delta = max(0, 本地 − 基线) = 0,云端会被写回旧值、本轮增量被吞。 // 直传即「云端 ← 并集」,各端独立贡献在各自 apply 阶段累入并集,天然满足跨端加算语义。 const builtForPush = utils.buildFullExportFile(webdavFullSelection()); const putResp = await webdavRequest({ url: remoteUrl, method: 'PUT', headers, body: JSON.stringify(builtForPush.file), timeout: 30000 }); const pushOk = putResp.status >= 200 && putResp.status < 300; const now = Date.now(); if (!pushOk) { // 回滚浏览历史存储与基线到合并前:本地与云端重新解耦,下轮 delta = 本地 − 旧基线 仍完整可算 try { if (thStoreBeforeMerge !== null) setThreadHistoryStore(thStoreBeforeMerge); if (kaoStoreBeforeMerge !== null) GM_setValue('kaomojiUsageStats', kaoStoreBeforeMerge); GM_setValue('xdex_webdav_history_baselines', thBaselineBeforeMerge || {}); } catch (e) { console.error('[webdav] 回滚同步快照失败', e); } console.warn('[webdav] 双向合并完成但回推失败(已回滚本地合并结果)', { status: putResp.status, report }); storeWebdavConfig(Object.assign({}, cfg, { lastSyncAt: cfg.lastSyncAt || 0 })); webdavUpdateLastSyncLabel(); setWebdavStatus('已尝试合并远端数据,回推云端失败(HTTP ' + putResp.status + '),本轮已还原待下轮重试'); if (!silent) notify('WebDAV:回推失败 HTTP ' + putResp.status + ',本轮改动已还原'); return { ok: false, reason: 'push-back-failed', report }; } storeWebdavConfig(Object.assign({}, cfg, { lastSyncAt: Math.max(now, remoteExportedAt || 0) })); webdavUpdateLastSyncLabel(); console.log('[webdav] 同步成功(双向合并)', { remoteExportedAt: remoteExportedAt || null, pushedAt: now, report }); const parts = []; if (report.settings && !settingsSkipped) parts.push('设置'); if (report.threadHistory) parts.push('浏览历史'); if (report.postHistory) parts.push('发言历史'); if (report.drafts) parts.push('草稿'); if (report.kaomojiStats) parts.push('颜文字统计'); if (report.cookiePrefs) parts.push('饼干偏好'); setWebdavStatus(settingsSkipped ? '双向合并完成(设置保留本地等待手动处理)' : '双向合并完成'); notify('WebDAV:双向合并完成(' + (parts.join('、') || '无变化') + ');可即时生效的设置已应用'); return { ok: true, direction: 'bidirectional' }; } async function webdavSyncNow() { saveWebdavConfigFromPanel(false); const cfg = getWebdavConfig(); if (!cfg.url) { if (typeof toast === 'function') toast('请先填写 WebDAV 链接'); return; } // 与自动同步共用互斥锁:避免手动/自动或多标签并发双写(30s 窗口) const now = webdavAutoNow(); const running = webdavAutoRead(WEBDAV_AUTO_RUNNING_KEY); if (now - running < 30000) { console.warn('[webdav] 手动同步跳过:其他同步正在进行'); if (typeof toast === 'function') toast('WebDAV:有同步正在进行,请稍后重试'); return; } webdavAutoWrite(WEBDAV_AUTO_RUNNING_KEY, now); // 手动同步后重置自动同步计时器:避免紧随的空转 webdavAutoWrite(WEBDAV_AUTO_NEXT_KEY, now + WEBDAV_AUTO_INTERVAL_MS); try { await webdavSyncCore(cfg, false); } finally { webdavAutoRemove(WEBDAV_AUTO_RUNNING_KEY); } } function webdavAutoSyncIfEnabled() { const cfg = getWebdavConfig(); if (!cfg.url || !cfg.autoSync) return; const now = webdavAutoNow(); const next = webdavAutoRead(WEBDAV_AUTO_NEXT_KEY); let scheduleHandle = 0; const trySync = () => { // 到点执行时重新取当前时间(排程等待期间可能已过去很久) const tryNow = webdavAutoNow(); // 复检开关:关闭自动同步后,本页已排程的定时器不再执行 if (!getWebdavConfig().autoSync) { console.log('[webdav] 自动同步跳过:开关已关闭'); return; } // 抢锁:避免多标签页同时同步;30s 窗口内其他页面跳过本次 const running = webdavAutoRead(WEBDAV_AUTO_RUNNING_KEY); if (tryNow - running < 30000) { // 同一端的其他页面正在同步:页面间共享同一份 GM 存储, // 持锁页面同步的就是全量共享数据,本页无需重复同步(更不应额外排重试); // next 已被其推进,本页随共享计划等待下一轮即可 console.log('[webdav] 自动同步跳过:其他页面正在同步(同端数据共享,无需重复同步)'); scheduleNext(); return; } // 抢到锁 webdavAutoWrite(WEBDAV_AUTO_RUNNING_KEY, tryNow); // 无论成败都推进下次时间,避免失败后每个页面都重试 webdavAutoWrite(WEBDAV_AUTO_NEXT_KEY, tryNow + WEBDAV_AUTO_INTERVAL_MS); console.log('[webdav] 自动同步开始', { at: new Date(tryNow).toLocaleString(), intervalMs: WEBDAV_AUTO_INTERVAL_MS }); const done = () => { webdavAutoRemove(WEBDAV_AUTO_RUNNING_KEY); // 同步结束(无论成败)续排下一次:页面存活期间不再依赖重载 scheduleNext(); }; try { webdavSyncCore(cfg, true).then(done, done); } catch (e) { done(); console.warn('[webdav] auto sync failed', e); } }; // 循环排程:每次按共享 next 排一次;到点执行后由 trySync 末尾再次调用本函数续排 const scheduleNext = () => { clearTimeout(scheduleHandle); const cfg2 = getWebdavConfig(); if (!cfg2.url || !cfg2.autoSync) return; const nextMs = Number(webdavAutoRead(WEBDAV_AUTO_NEXT_KEY)) || 0; const now2 = webdavAutoNow(); const delay = nextMs ? Math.max(1000, Math.min(nextMs - now2, WEBDAV_AUTO_INTERVAL_MS)) : WEBDAV_AUTO_INTERVAL_MS; console.log('[webdav] 自动同步已排程', { at: new Date(now2 + delay).toLocaleString(), delayMs: delay }); scheduleHandle = setTimeout(trySync, delay); }; if (!next || now >= next) { // 首次或已到点(含上次页面关闭未同步):立即补一次(trySync 完成后会续排) console.log('[webdav] 自动同步到点,立即执行', { next: next ? new Date(next).toLocaleString() : '首次' }); trySync(); return; } // 未到点:排程等待;执行后由 trySync 续排,页面存活也能持续自动同步 scheduleNext(); } function bindWebdavPanelEvents() { // 原生 DOM 委托(capture 阶段),不依赖 jQuery / 面板重建时机 / 脚本管理器注入顺序 try { // 划词抑制:面板内 mousedown 后位移超过 5px 视为拖选,阻止 click 默认行为与面板外误关 let webdavDragStart = null; document.addEventListener('mousedown', function onWebdavDragStart(e) { if (e.button !== 0) { webdavDragStart = null; return; } const t = e.target; webdavDragStart = (t && t.closest && t.closest('#sp_panel, #sp_cover')) ? { x: e.clientX, y: e.clientY } : null; }, true); document.addEventListener('click', function onWebdavDragSuppress(e) { if (!webdavDragStart) return; const dx = e.clientX - webdavDragStart.x; const dy = e.clientY - webdavDragStart.y; if (Math.sqrt(dx * dx + dy * dy) > 5) { e.preventDefault(); e.stopImmediatePropagation(); } webdavDragStart = null; }, true); // 自动同步开关:变化即保存并提示;开启时立即同步一次(无需再点保存) document.addEventListener('change', function onWebdavAutoSyncChange(e) { const t = e && e.target; if (!t || t.id !== 'sp_webdavAutoSync') return; console.log('[webdav] 自动同步开关变化', { checked: t.checked }); saveWebdavConfigFromPanel(false); const cfg = getWebdavConfig(); if (t.checked) { if (typeof toast === 'function') toast('已开启自动同步'); if (cfg.url) webdavTriggerImmediateSync(cfg); } else { if (typeof toast === 'function') toast('已关闭自动同步'); } }, true); document.addEventListener('click', function onWebdavDelegateClick(e) { const t = e && e.target; if (!t || typeof t.closest !== 'function') return; const btn = t.closest('#btn_webdavCheck, #btn_webdavSync, #btn_sp_webdavSave, #btn_webdavTogglePassword'); if (!btn || !btn.id) return; console.log('[webdav] 点击', { id: btn.id, at: Date.now() }); if (btn.id === 'btn_sp_webdavSave') { e.preventDefault(); e.stopPropagation(); try { ensureWebdavConfigInPanel(); saveWebdavConfigFromPanel(true); } catch (err) { console.error('[webdav] 保存处理异常', err); setWebdavStatus('保存异常:' + (err && err.message ? err.message : err)); } return; } if (btn.id === 'btn_webdavCheck') { e.preventDefault(); e.stopPropagation(); try { ensureWebdavConfigInPanel(); saveWebdavConfigFromPanel(false); const cfg = getWebdavConfig(); if (!cfg.url) { console.warn('[webdav] 检查连接:未填写链接'); setWebdavStatus('请先填写 WebDAV 链接'); return; } console.log('[webdav] 检查连接开始', { url: cfg.url, username: cfg.username ? '已填' : '未填', password: cfg.password ? '已填' : '未填' }); setWebdavStatus('正在检查连接……'); checkWebdavConnection(cfg).then((conn) => { console.log('[webdav] 检查连接结果', conn); setWebdavStatus(conn.message); if (typeof toast === 'function') toast(conn.ok ? 'WebDAV ' + conn.message : 'WebDAV 连接失败:' + conn.message); }).catch((err) => { console.error('[webdav] 检查连接异常', err); setWebdavStatus('检查连接异常:' + (err && err.message ? err.message : err)); if (typeof toast === 'function') toast('WebDAV 检查连接异常'); }); } catch (err) { console.error('[webdav] 检查连接处理异常', err); setWebdavStatus('检查连接异常:' + (err && err.message ? err.message : err)); } return; } if (btn.id === 'btn_webdavTogglePassword') { e.preventDefault(); e.stopPropagation(); const input = document.getElementById('sp_webdavPassword'); if (input) { const showing = input.type === 'text'; input.type = showing ? 'password' : 'text'; // 图标切换:密码显示 → 闭眼(点击后隐藏);密码隐藏 → 睁眼(点击后显示) btn.innerHTML = showing ? XDEX_ICON_EYE : XDEX_ICON_EYE_OFF; btn.title = showing ? '显示密码' : '隐藏密码'; console.log('[webdav] 密码可见性切换', { showing: !showing }); } return; } if (btn.id === 'btn_webdavSync') { e.preventDefault(); e.stopPropagation(); btn.classList.add('xdex-icon-loading'); webdavSyncNow().catch((err) => { console.error('[webdav] 手动同步异常', err); setWebdavStatus('同步异常:' + (err && err.message ? err.message : err)); }).finally(() => { btn.classList.remove('xdex-icon-loading'); }); } }, true); // 面板重建后回填已保存配置:聚焦输入框时空值则回填 document.addEventListener('focusin', function onWebdavFocusin(e) { const t = e && e.target; if (!t || !t.id) return; if (t.id === 'sp_webdavUrl' || t.id === 'sp_webdavUsername' || t.id === 'sp_webdavPassword') { try { ensureWebdavConfigInPanel(); } catch (err) { console.warn('[webdav] 回填异常', err); } } }, true); // 切回标签页时刷新灰字:其他页面完成同步后,此页面打开状态也能同步显示 window.addEventListener('focus', function onWebdavWindowFocus() { try { webdavUpdateLastSyncLabel(); } catch (e) {} }); console.log('[webdav] 面板事件绑定完成(原生委托)'); return true; } catch (e) { console.error('[webdav] 绑定失败', e); return false; } } // 立即绑定一次即可:原生 document 监听不依赖 jQuery 就绪 bindWebdavPanelEvents(); // 自动同步:页面 load 后延迟执行,避开启动编排与发言历史回查高峰期 window.addEventListener('load', () => { setTimeout(() => { webdavAutoSyncIfEnabled(); }, 4000); }); /* -------------------------------------------------- * tag 30. 测试板块增强:侧边栏入口 + 主串列表恢复 * -------------------------------------------------- */ // 测试版(fid=122)不在站点公开的侧边栏列表里(与值班室同组但被隐藏),补一个入口。 // 链接与样式与同组其他链接(值班室、三百人委员会等)完全一致。 // ★ 当前页/串来自测试板块时,同时展开「管理」分组(服务端不知道该板块属于侧边栏哪个分组, // 不会加 uk-open;值班室等默认板块则会展开——这里补齐一致体验)。 // 识别判据(覆盖版块页与串内页): // 1) URL 路径以 /f/测试 开头(版块页;复用 PageType 统一解析) // 2) 发串表单隐藏域 input[name=fid][value=122](版块页与串内页均有表单) // 3) 面包屑中存在 /f/%E6%B5%8B%E8%AF%95 链接(版块页;串内页面包屑不含板块) // 4) forum-info 脚本的 forum.id === '122'(仅版块页) // 5) 串内页兜底:当前串号 ∈ 测试版串号集合(showf API 拉取,1h 缓存) // —— 串内页 URL/表单/面包屑/forum-info 均不含板块信息,只能靠串号归属判定 function isTestBoardContext() { try { // 仅 /f/测试(值班室虽同属特殊板块,但不是测试上下文) if (/^\/f\/%E6%B5%8B%E8%AF%95(?:\/|$)/i.test(location.pathname) || /^\/f\/测试(?:\/|$)/.test(decodeURIComponent(location.pathname))) return true; if (document.querySelector('#h-post-form input[name="fid"][value="122"], form input[name="fid"][value="122"]')) return true; // ★ 串内页主判据:面包屑含板块链接(服务端会输出当前串所属板块)。 // href 可能是编码态(/f/%E6%B5%8B%E8%AF%95)或明文态(/f/测试),逐链接解码后比对 const bc = document.querySelector('.uk-breadcrumb'); if (bc) { const links = bc.querySelectorAll('a[href^="/f/"]'); for (const a of links) { const href = a.getAttribute('href') || ''; let decoded = href; try { decoded = decodeURIComponent(href); } catch (e) {} if (/^\/f\/测试(?:\/|$)/.test(decoded)) return true; } } const info = document.getElementById('forum-info'); if (info && /"id"\s*:\s*"122"/.test(info.textContent)) return true; } catch (e) {} return false; } // 展开「管理」分组(与站点服务端对默认板块的 uk-open 行为对齐) // ⚠️ UIkit 竞态:站点内联脚本 ready 时会初始化 nav 组件并按服务端初始状态重设高度—— // 我们在 early pass 加的 uk-open/height 会被它覆盖(实测版块页 uk-open 丢失、串内页 height 归 0)。 // 因此展开分两段:立即设一次;ready 后延迟(500ms/1200ms)再重申,并使用具体像素高度 // (原生展开态是 height:130px 这类具体值,UIkit 对 auto 支持不佳)。 function expandAdminGroupIfTestBoard(alreadyMatched) { if (!alreadyMatched && !isTestBoardContext()) return false; const menu = document.getElementById('h-menu-content') || document.querySelector('.h-menu-content'); if (!menu) return false; const groupLi = Array.from(menu.children).find((li) => { if (!li || !li.querySelector) return false; const header = li.querySelector(':scope > .h-nav-parent-header, :scope > a.h-nav-parent-header'); return header && (header.textContent || '').trim() === '管理'; }); if (!groupLi) return false; const applyExpand = () => { groupLi.classList.add('uk-open'); groupLi.setAttribute('aria-expanded', 'true'); const wrap = groupLi.querySelector(':scope > div'); if (wrap) { wrap.classList.remove('uk-hidden'); const sub = wrap.querySelector('ul.uk-nav-sub'); // 使用具体像素高度(对齐原生展开态 height:130px 的形态),随内容自适应 const h = sub && sub.scrollHeight ? sub.scrollHeight + 'px' : 'auto'; wrap.style.height = h; } // 链接样式对齐原生当前版块:ultimate-bold-correct + li.h-active // 判定用「测试板块上下文」整体(版块页与串内页均算——串内页面包屑指向测试板块, // 原生对值班室串内页同样高亮其板块链接) const link = groupLi.querySelector('a[data-xdex-test-board-link]'); if (link) { const active = isTestBoardContext(); link.classList.toggle('ultimate-bold-correct', active); if (active && link.parentElement) link.parentElement.classList.add('h-active'); } }; applyExpand(); // 重申:覆盖 UIkit 组件初始化的覆盖(ready 后 500ms / 1200ms 各一次) const reassert = () => { try { applyExpand(); } catch (e) {} }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { setTimeout(reassert, 500); setTimeout(reassert, 1200); }, { once: true }); } else { setTimeout(reassert, 500); setTimeout(reassert, 1200); } return true; } function ensureTestBoardSidebarLink() { const menu = document.getElementById('h-menu-content') || document.querySelector('.h-menu-content'); if (!menu) return false; const contextMatched = isTestBoardContext(); // 幂等:已存在则跳过(同时保证展开状态被应用) if (menu.querySelector('a[data-xdex-test-board-link]')) { expandAdminGroupIfTestBoard(contextMatched); return true; } // 定位「管理」分组:<li class="uk-parent"> > a.h-nav-parent-header 文本为「管理」 const groupLi = Array.from(menu.children).find((li) => { if (!li || !li.querySelector) return false; const header = li.querySelector(':scope > .h-nav-parent-header, :scope > a.h-nav-parent-header'); return header && (header.textContent || '').trim() === '管理'; }); if (!groupLi) return false; const sub = groupLi.querySelector(':scope > ul.uk-nav-sub') || groupLi.querySelector(':scope > div > ul.uk-nav-sub') || groupLi.querySelector('ul.uk-nav-sub'); if (!sub) return false; // 以「三百人委员会」为锚点插到其后;找不到锚点则追加到子列表末尾 const anchor = Array.from(sub.children).find((li) => { const a = li.querySelector && li.querySelector(':scope > a'); return a && (a.textContent || '').trim() === '三百人委员会'; }); const li = document.createElement('li'); const link = document.createElement('a'); link.href = 'https://www.nmbxd1.com/f/%E6%B5%8B%E8%AF%95'; link.textContent = '测试'; link.setAttribute('data-xdex-test-board-link', '1'); li.appendChild(link); if (anchor && anchor.nextSibling) sub.insertBefore(li, anchor.nextSibling); else sub.appendChild(li); expandAdminGroupIfTestBoard(contextMatched); return true; } // 侧边栏可能晚于脚本渲染(站点异步构建),装观察器兜底补挂 function installTestBoardSidebarLinkObserver() { ensureTestBoardSidebarLink(); if (installTestBoardSidebarLinkObserver.__installed) return; installTestBoardSidebarLinkObserver.__installed = true; const observer = new MutationObserver(() => { ensureTestBoardSidebarLink(); }); observer.observe(document.body, { childList: true, subtree: true }); } // ★ 测试版主串列表守护(v2): // 站点内联脚本对 /f/测试 的处理是「无条件 remove() 主串列表与分页,再请求 /Admin/Api/isManager, // 仅管理员放回」。但服务端对已登录用户正常下发了完整列表(含主串),移除只发生在客户端。 // // v2 方案(取代 v1 的「事后拉取放回」): // 文档起始即装 MutationObserver 守护 document.documentElement,监听 removedNodes—— // .h-threads-list / .h-pagination 一旦被移除,立刻在原父容器的原位置放回**同一个节点**。 // 优点: // 1. 移除与放回发生在同一帧内,肉眼无闪变; // 2. 节点是服务端原始 DOM,早期增强 pass(回复编号/PO标记/页码扩充等)正常作用; // 3. 原始分页结构完好,无缝翻页可用;零额外网络请求; // 4. 站点管理员分支的 container.append(threadList) 是移动语义(jQuery append), // 节点已在容器内时为先 remove 后 append,位置可能挪到容器末尾,无实质影响。 function installTestBoardListGuard() { if (!PageType.isSpecialBoard()) return; if (installTestBoardListGuard.__installed) return; installTestBoardListGuard.__installed = true; const GUARD_SEL = '.h-threads-list, .h-pagination'; // 只守护主内容区(#h-content)内的列表与分页: // 站点脚本 $(".h-pagination") 会把侧边栏/管理分组里的分页也一并移除——那是站点自身行为, // 管理员放回分支也只处理主内容区,我们不越界代管,避免节点被放回错误容器。 const inMainContent = (node) => !!(node.closest && node.closest('#h-content')); // ★ 结构化锚定(站点真实结构,见 /f/测试 原始 HTML): // #h-post-form(含 form[action=doPostThread])→ <hr>(hr①)→ .h-threads-list → .h-pagination // → <script id="forum-info">(其前有 <hr>,hr②)→ #h-footer // 列表应放回 hr① 之后;分页放回列表之后。不再依赖通用位置表(同批次多个 removal 时失效)。 const findListAnchor = () => { const container = document.querySelector('.uk-container'); if (!container) return null; const form = container.querySelector('#h-post-form form[action="/Home/Forum/doPostThread.html"]') || container.querySelector('#h-post-form'); if (!form) return null; // hr① = 表单容器之后的第一个 <hr> let node = form.parentElement === container ? form : container.querySelector('#h-post-form'); while (node && node.nextElementSibling) { node = node.nextElementSibling; if (node.tagName === 'HR') return { hr: node, container }; } return { hr: null, container }; // 无 hr①:插在表单容器之后 }; const placeList = (list) => { const anchor = findListAnchor(); if (!anchor) return false; try { if (anchor.hr && anchor.hr.parentElement === anchor.container) { anchor.container.insertBefore(list, anchor.hr.nextSibling); } else { anchor.container.insertBefore(list, anchor.hr || null); } // 分页紧跟列表之后(若分页节点游离,一并归位) const navi = document.querySelector('.h-pagination'); if (navi && navi.parentElement !== list.parentElement) { list.parentElement.insertBefore(navi, list.nextSibling); } return true; } catch (e) { return false; } }; const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (!mutation.removedNodes || !mutation.removedNodes.length) continue; for (const node of mutation.removedNodes) { if (node.nodeType !== 1) continue; if (!node.matches || !node.matches(GUARD_SEL)) continue; // ★ 只守护「站点原生分页」:enablePaginationDuplication 克隆的页首页码(titlePag) // 不在守护范围——它的插入/移除由页码功能自己管理,守护器若放回会与其去重逻辑死循环 if (node.dataset && node.dataset.xdexTitleClone === '1') continue; // 无缝翻页接管标记:replaceWith 更新页码前的有意移除,守护器放行 if (node.dataset && node.dataset.xdexSeamlessReplaced === '1') continue; // 列表:结构化放回(hr① 之后) if (node.classList.contains('h-threads-list')) { if (placeList(node)) console.info('[X岛-EX][测试版列表] 已守护放回(hr① 下方)'); continue; } // 分页:结构化放回列表之后(页底) if (node.classList.contains('h-pagination')) { const list = document.querySelector('#h-content .h-threads-list'); const container = document.querySelector('.uk-container'); try { if (list && list.parentElement === container) { container.insertBefore(node, list.nextSibling); console.info('[X岛-EX][测试版分页] 已守护放回页底 (items=' + node.querySelectorAll('li').length + ')'); } else if (container) { const info = container.querySelector('script#forum-info'); container.insertBefore(node, info || document.getElementById('h-footer') || null); console.info('[X岛-EX][测试版分页] 已守护放回(列表未就位,暂放 forum-info 前)'); } else { console.info('[X岛-EX][测试版分页] 未找到 .uk-container,跳过'); } } catch (e) { console.warn('[X岛-EX][测试版分页] 放回失败', e); } } } } }); const start = () => { if (!document.documentElement) { setTimeout(start, 25); return; } observer.observe(document.documentElement, { childList: true, subtree: true }); }; start(); } // ★ 兜底:守护器万一失手(极端竞态/父容器销毁),ready+400ms 后检查一次并网络拉取放回。 // 正常情况下守护器已放回节点,这里直接返回;保留 v1 逻辑作为最后防线。 function restoreTestBoardThreadList() { if (!PageType.isSpecialBoard()) return; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(restoreTestBoardThreadList, 400), { once: true }); return; } setTimeout(restoreTestBoardThreadList.__attempt, 400); } restoreTestBoardThreadList.__attempt = function __attempt() { if (!PageType.isSpecialBoard()) return; if (document.querySelector('.h-threads-list')) return; // 已有列表(管理员或已守护恢复) if (restoreTestBoardThreadList.__running) return; restoreTestBoardThreadList.__running = true; const container = document.querySelector('.uk-container'); if (!container) { restoreTestBoardThreadList.__running = false; return; } fetch(location.href, { credentials: 'same-origin' }) .then((res) => (res.ok ? res.text() : Promise.reject(new Error('HTTP ' + res.status)))) .then((html) => { const doc = new DOMParser().parseFromString(html, 'text/html'); const list = doc.querySelector('.h-threads-list'); if (!list) return; // 服务端也没给(未登录等),保持站点原状 if (document.querySelector('.h-threads-list')) return; // 竞态:站点脚本已放回 const navi = doc.querySelector('.h-pagination'); container.appendChild(list); if (navi) container.appendChild(navi); console.info('[X岛-EX] 测试版主串列表已通过兜底拉取恢复'); }) .catch((e) => console.warn('[X岛-EX] 测试版列表恢复失败', e)) .finally(() => { restoreTestBoardThreadList.__running = false; }); }; /* -------------------------------------------------- * tag 31. 串内容导出(扩展后台任务) * -------------------------------------------------- */ // 页面内提供:sp_btn 下方的「导」按钮 + 提交弹窗 + 缩略进度条。 // 任务由扩展 Service Worker 在后台执行(页面可关闭),进度经 chrome.storage 同步。 // 非扩展环境(Tampermonkey / QwQNT)暂不提供实现(分支留空)。 const XDEX_EXPORT_TASKS_KEY = 'xdex-export-tasks'; const XDEX_EXPORT_UI_ID = 'xdex-export-ui'; // 扩展环境:内容脚本里可直接用 chrome.runtime(与主脚本同处 ISOLATED world) function xdexExportExtensionAvailable() { try { return !!(typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id && chrome.runtime.sendMessage); } catch (e) { return false; } } // 脚本猫桥接:主脚本不声明 @storageName,仅经 DOM 事件与独立的桥接脚本通信。 // 为什么不用 window 属性握手:脚本猫给每个脚本独立的沙箱 window(沙箱属性写入不会 // 泄漏到 unsafeWindow),主脚本读不到桥接写在自己 window 上的对象。 // DOM 是共享的,因此以 documentElement 上的标记作为存在性信号, // 请求/响应一律走 DOM 事件(不需要任何跨沙箱的函数引用)。 function xdexExportBridge() { try { const root = document.documentElement; const mark = root && root.dataset ? root.dataset.xdexExportBridge : ''; if (mark) return { available: true, version: mark }; } catch (e) { /* 忽略 */ } try { const w = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : null; const b = w ? w.__xdexExportBridge : null; if (b && b.available) return b; } catch (e) { /* 忽略 */ } return null; } function xdexExportRuntimeAvailable() { return xdexExportExtensionAvailable() || !!xdexExportBridge(); } function xdexExportExtensionSend(message) { return new Promise((resolve, reject) => { try { chrome.runtime.sendMessage(message, (response) => { const err = chrome.runtime.lastError; if (err) { reject(new Error(err.message)); return; } if (!response) { reject(new Error('扩展后台无响应')); return; } if (response.ok === false) { reject(new Error(response.error || '操作失败')); return; } resolve(response.data); }); } catch (e) { reject(e); } }); } // 经桥接脚本转发:请求用 DOM 事件送出去,响应也用 DOM 事件送回来, // 桥接负责与后台脚本交换共享存储。requestId 用于把异步响应配回本次调用。 // timeoutMs:快速请求 30s;submit 只等「受理回执」(后台接到即回,抓取在其后台继续), // 因此给 60s 兜底即可 —— 不像以前那样不设超时,避免后台未运行时永久卡在「正在提交…」。 // 进度由 list 轮询呈现(按需启停,无任务时不轮询)。 function xdexExportBridgeSend(bridge, message, timeoutMs) { return new Promise((resolve, reject) => { const requestId = 'xdex-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); let settled = false; const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); document.removeEventListener('xdex:export-result', onResult); fn(arg); }; const onResult = (ev) => { const d = (ev && ev.detail) || {}; if (d.requestId !== requestId) return; if (d.ok === false) finish(reject, new Error(d.error || '导出操作失败')); else finish(resolve, d.data); }; const timer = timeoutMs > 0 ? setTimeout(() => finish(reject, new Error( '后台脚本无响应(超时)——请确认「X岛-EX · 脚本猫后台导出」已在脚本猫里【运行/启用】')), timeoutMs) : null; document.addEventListener('xdex:export-result', onResult); try { // 直接派发 DOM 事件(DOM 跨沙箱共享);若桥接对象恰好可用则优先用它的方法 if (bridge && typeof bridge.request === 'function') { bridge.request(message.type, message.details || {}, requestId); } else { document.dispatchEvent(new CustomEvent('xdex:export-request', { detail: { type: String(message.type || ''), details: message.details || {}, requestId } })); } } catch (e) { finish(reject, e); } }); } // HTML → 纯文本(用于从主串正文里取标题)。 // 采用「去标签 + 折叠空白」这一最小规则,以便与扩展端/脚本猫后台的实现逐字一致。 function xdexExportHtmlToPlain(html) { return String(html == null ? '' : html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); } // 标题取用规则(与后台落盘命名用的是同一套): // 有标题且不是「无标题」→ 用原标题; // 否则取主串正文的「第一句话」—— 到第一个句末标点(含)为止,没有则整段;最后统一截到 40 字。 function xdexExportPickTitle(title, contentHtml) { const t = String(title || '').trim(); if (t && t !== '无标题') return t; const text = xdexExportHtmlToPlain(contentHtml); if (!text) return ''; const m = text.match(/^[^。!?!?]{1,60}[。!?!?]/); return (m ? m[0] : text).slice(0, 40).trim(); } // 检索限流:批量检索时按顺序、按最小间隔发请求,避免短时间对站点发太多请求 const XDEX_EXPORT_PROBE_MIN_INTERVAL_MS = 1000; let xdexExportProbeLastAt = 0; let xdexExportProbeChain = Promise.resolve(); function xdexExportProbeThrottle() { const run = xdexExportProbeChain .then(() => { const wait = xdexExportProbeLastAt + XDEX_EXPORT_PROBE_MIN_INTERVAL_MS - Date.now(); return wait > 0 ? new Promise((r) => setTimeout(r, wait)) : Promise.resolve(); }) .then(() => { xdexExportProbeLastAt = Date.now(); }); xdexExportProbeChain = run.catch(() => {}); return run; } // 本地探测:脚本猫环境下直接用主脚本自己的 GM_xmlhttpRequest 取串信息, // 无需后台脚本参与 —— 这样「检索」在后台未运行/未启用时依然可用。 function xdexExportLocalProbe(details) { const tid = String((details && details.tid) || '').trim(); if (!/^\d{4,9}$/.test(tid)) return Promise.reject(new Error('串号格式不正确')); const headers = (typeof getPostHistoryApiCookieHeaders === 'function') ? getPostHistoryApiCookieHeaders() : null; const url = `https://api.nmb.best/api/thread?id=${encodeURIComponent(tid)}&page=1`; return xdexExportProbeThrottle().then(() => gmRequest(url, 'text', headers)).then((resp) => { const raw = resp && (resp.response !== undefined ? resp.response : resp.responseText); let data = raw; if (typeof raw === 'string') { const text = raw.trim(); // 站点在串不存在时返回 JSON 字符串字面量或裸文本,都要解出可读文案 if (text.startsWith('"')) { try { throw new Error(JSON.parse(text)); } catch (e) { if (e instanceof Error) throw e; } } if (!text.startsWith('{') && !text.startsWith('[')) { throw new Error(text || '接口返回了无法解析的内容'); } try { data = JSON.parse(text); } catch (e) { throw new Error('接口返回的 JSON 无法解析'); } } if (!data || data.success === false) { throw new Error((data && data.error) || '接口返回错误'); } const totalReplies = Number(data.ReplyCount != null ? data.ReplyCount : data.reply_count) || 0; return { tid, // 检索展示与后台落盘命名使用同一套标题规则:无标题时取主串第一句话 title: xdexExportPickTitle(data.title, data.content), rawTitle: String(data.title || '').trim(), fid: String(data.fid || '').trim(), totalPages: Math.max(1, Math.ceil(totalReplies / 19)), totalReplies }; }); } // 快速请求(probe/list/cancel)超时短;submit 只等受理回执,给足 offscreen 冷启动余量 const XDEX_EXPORT_BRIDGE_TIMEOUT_MS = 30000; const XDEX_EXPORT_BRIDGE_ACK_TIMEOUT_MS = 60000; function xdexExportSend(message) { if (xdexExportExtensionAvailable()) return xdexExportExtensionSend(message); const bridge = xdexExportBridge(); if (bridge) { // 检索不依赖后台脚本:主脚本自己就能取串信息(后台未启用时也能用) if (message && message.type === 'xdex:export:probe') { return xdexExportLocalProbe(message.details || {}); } // 后台对 submit 是「立即回执」(抓取在其后台继续),所以这里等的是受理回执, // 给一个兜底超时即可 —— 后台未运行时给提示而不是永久卡在「正在提交…」。 return xdexExportBridgeSend(bridge, message, XDEX_EXPORT_BRIDGE_ACK_TIMEOUT_MS); } return Promise.reject(new Error('导出需要扩展版 X岛-EX 或脚本猫桥接脚本')); } // 解析用户输入的串号:支持 `67024789`、`No.67024789`、URL、`67024789?r=x&page=23`, // 逗号/换行/空格分隔批量。返回 { tid, raw, ok, error } function xdexExportParseTidList(input) { const parts = String(input == null ? '' : input) .split(/[,\n\uFF0C;;]+/) .map((t) => t.trim()) .filter(Boolean); return parts.map((raw) => { let text = raw; // 完整 URL:取路径中的 /t/{id} 或 /Forum/po/id/{id} const urlMatch = text.match(/\/t\/(\d{4,9})/) || text.match(/\/Forum\/po\/id\/(\d{4,9})/); if (urlMatch) { return { tid: urlMatch[1], raw, ok: true, error: '' }; } // 去掉 No. / # 前缀,去掉 ?query 与 &page= 尾部 text = text.replace(/^(?:No\.?|#|#)\s*/i, '').split(/[?&\s]/)[0]; // 允许 `67024789-67024790` 之类只取首段 const numMatch = text.match(/^(\d{4,9})/); if (numMatch) { return { tid: numMatch[1], raw, ok: true, error: '' }; } return { tid: '', raw, ok: false, error: '无法识别的串号' }; }); } function xdexExportCurrentTid() { try { const m = String(location.pathname || '').match(/\/(?:t|Forum\/po\/id)\/(\d{4,9})/); return m ? m[1] : ''; } catch (e) { return ''; } } function xdexExportEscapeHtml(text) { return String(text == null ? '' : text) .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // ---- 缩略进度条(sp_btn 下方导出按钮之下)---- // 任务进度百分比与状态文案(缩略条主行与展开列表共用) // 任务本次的「计划页数」:提交 1-10 时是 10,而不是该串的总页数 323。 // to > 0 → 区间长度;to === 0(全部页哨兵)→ 用该串总页数减去起始页偏移。 function xdexExportMiniPlannedPages(task) { const from = Math.max(1, Number(task.from) || 1); const to = Math.max(0, Number(task.to) || 0); const total = Number(task.totalPages) || 0; if (to > 0) return Math.max(0, to - from + 1); if (total > 0) return Math.max(0, total - from + 1); return 0; } function xdexExportMiniPercent(task) { const done = (task.progress && task.progress.donePages) ? task.progress.donePages.length : 0; // 分母必须是「本次计划页数」。三层来源,逐级回退: // 1) progress.totalPages(后台抓到首页后 / 新版入队时写入) // 2) 由提交区间(task.from/to)推算 // 注意:绝不能用 task.totalPages —— 那是「该串的总页数」,会把 1-10 显示成 0/323。 const planned = Number(task.progress && task.progress.totalPages) || xdexExportMiniPlannedPages(task) || Number(task.progress && task.progress.totalPages) /* 0 → 保持 0 */; return { done, total: planned, percent: planned > 0 ? Math.min(100, Math.round((done / planned) * 100)) : 0 }; } function xdexExportMiniLabel(task) { const { done, total } = xdexExportMiniPercent(task); if (task.status === 'done') return '已完成'; if (task.status === 'failed') return task.error ? `失败:${task.error}` : '失败'; if (task.status === 'canceled') return `已取消(已导出 ${done} 页)`; return `${done}/${total || '?'}`; } // 任务标题:优先任务自带 title,退回串号 function xdexExportMiniTitle(task) { const t = String(task.title || '').trim(); return t && t !== '无标题' ? t : `串${task.tid}`; } function xdexExportRenderMini(task, moreCount) { const mini = document.getElementById('xdex-export-mini'); if (!mini) return; if (!task) { mini.style.display = 'none'; mini.innerHTML = ''; return; } const { percent } = xdexExportMiniPercent(task); mini.style.display = 'block'; mini.innerHTML = '' + `<div class="xdex-export-mini-head">` + `<span class="xdex-export-mini-title" title="${xdexExportEscapeHtml(xdexExportMiniTitle(task))}">${xdexExportEscapeHtml(xdexExportMiniTitle(task))}</span>` + `<span class="xdex-export-mini-tid">No.${xdexExportEscapeHtml(task.tid)}</span>` + `<button type="button" class="xdex-export-mini-btn" data-act="expand" title="${moreCount > 0 ? (xdexExportMiniExpanded ? '收起' : '展开全部任务') : '仅一个任务'}">${moreCount > 0 ? '+' + moreCount + ' ' : ''}${xdexExportMiniExpanded && moreCount > 0 ? '▴' : '▾'}</button>` + `<button type="button" class="xdex-export-mini-btn" data-act="cancel" title="取消任务(已下载的页会照常导出)">×</button></div>` + `<div class="xdex-export-mini-track"><div class="xdex-export-mini-fill" style="width:${percent}%"></div></div>` + `<div class="xdex-export-mini-label">${xdexExportEscapeHtml(xdexExportMiniLabel(task))}</div>`; } // 缩略条展开态(轮询重渲染时保持,否则每秒会被 innerHTML 重写折叠) let xdexExportMiniExpanded = false; function xdexExportRenderMiniList(tasks) { const mini = document.getElementById('xdex-export-mini'); if (!mini) return; if (!tasks || !tasks.length) { xdexExportMiniExpanded = false; xdexExportRenderMini(null, 0); return; } const active = tasks.filter((t) => t.status === 'running' || t.status === 'queued'); const list = active.length ? active : tasks.slice(0, 1); // 多任务:显示第一个,点击「+N ▾」展开其余 xdexExportRenderMini(list[0], Math.max(0, list.length - 1)); const expandBtn = mini.querySelector('[data-act="expand"]'); if (expandBtn && list.length > 1) { expandBtn.addEventListener('click', () => { xdexExportMiniExpanded = !xdexExportMiniExpanded; xdexExportRenderMiniList(tasks); }); } // 保持展开态(重渲染后重新挂载展开列表) if (xdexExportMiniExpanded && list.length > 1) { xdexExportRenderMiniMore(mini, list); } const cancelBtn = mini.querySelector('[data-act="cancel"]'); if (cancelBtn) { cancelBtn.addEventListener('click', () => { xdexExportSend({ type: 'xdex:export:cancel', details: { id: list[0].id } }) .then(() => xdexExportRefreshState()) .catch(() => {}); }); } } // 展开列表:每个任务都显示「标题 + 串号 + 进度条 + 状态」,并各自带取消按钮 function xdexExportRenderMiniMore(mini, tasks) { const existing = mini.querySelector('.xdex-export-mini-more'); if (existing) existing.remove(); const more = document.createElement('div'); more.className = 'xdex-export-mini-more'; tasks.slice(1).forEach((task) => { const { percent } = xdexExportMiniPercent(task); const title = xdexExportMiniTitle(task); const row = document.createElement('div'); row.className = 'xdex-export-mini-more-row'; // 头部:标题 + 串号 + 取消按钮(全部用 DOM 构造,避免 innerHTML 后再查询) const head = document.createElement('div'); head.className = 'xdex-export-mini-more-head'; const titleEl = document.createElement('span'); titleEl.className = 'xdex-export-mini-more-title'; titleEl.textContent = title; titleEl.title = title; const tidEl = document.createElement('span'); tidEl.className = 'xdex-export-mini-more-tid'; tidEl.textContent = `No.${task.tid}`; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'xdex-export-mini-btn'; btn.title = '取消任务(已下载的页会照常导出)'; btn.textContent = '×'; btn.addEventListener('click', () => { xdexExportSend({ type: 'xdex:export:cancel', details: { id: task.id } }) .then(() => xdexExportRefreshState()) .catch(() => {}); }); head.appendChild(titleEl); head.appendChild(tidEl); head.appendChild(btn); // 进度条 const track = document.createElement('div'); track.className = 'xdex-export-mini-more-track'; const fill = document.createElement('div'); fill.className = 'xdex-export-mini-more-fill'; fill.style.width = `${percent}%`; track.appendChild(fill); // 状态文案 const label = document.createElement('div'); label.className = 'xdex-export-mini-more-label'; label.textContent = xdexExportMiniLabel(task); row.appendChild(head); row.appendChild(track); row.appendChild(label); more.appendChild(row); }); mini.appendChild(more); } // 进度轮询按需启停:无在跑任务即停止,避免在未使用导出功能时长期空转。 let xdexExportIdleTicks = 0; let xdexExportStallSig = ''; let xdexExportStallSince = 0; // 僵死判定阈值:按「单页最坏耗时」的 2 倍取值。 // fetchText 超时 20s,且每页最多按顺序试 3 个 Cookie 来源 → 单页最坏 60s; // 正常下载时状态约每 0.3~1s 就变一次(每抓完一页写一次进度)。 // 因此「连续 2 分钟毫无变化」对正常下载是不可能的,可据此判定僵死。 const XDEX_EXPORT_STALL_MS = 2 * 60 * 1000; function xdexExportEnsureProgressPolling() { if (xdexExportPollTimer) return; let inFlight = false; xdexExportPollTimer = setInterval(() => { if (document.hidden || inFlight) return; // 首次未知(本页尚未取过状态)→ 探测一次;之后按是否有在跑任务决定是否继续 const st = xdexExportLastState; const active = (st === null) || !!(st.tasks && st.tasks.length); if (!active) { xdexExportIdleTicks += 1; if (xdexExportIdleTicks >= 2) { clearInterval(xdexExportPollTimer); xdexExportPollTimer = null; xdexExportIdleTicks = 0; } return; } xdexExportIdleTicks = 0; // 僵死保护:任务表连续 XDEX_EXPORT_STALL_MS 毫无变化则停止轮询 const sig = (st.tasks || []).map((t) => `${t.id}:${t.status}:${(t.progress && t.progress.donePages ? t.progress.donePages.length : 0)}`).join('|'); if (sig !== xdexExportStallSig) { xdexExportStallSig = sig; xdexExportStallSince = Date.now(); } else if (Date.now() - xdexExportStallSince > XDEX_EXPORT_STALL_MS) { // 判定僵死:停止轮询,并清掉缩略条 —— 否则会一直显示一个永远不动的任务 clearInterval(xdexExportPollTimer); xdexExportPollTimer = null; xdexExportLastState = null; try { xdexExportRenderMiniList([]); } catch (_e) { /* 忽略 */ } console.warn('[X岛-EX 导出] 任务进度长时间无变化,已停止轮询并隐藏进度条'); return; } inFlight = true; Promise.resolve(xdexExportRefreshState()).catch(() => {}).then(() => { inFlight = false; }); }, 3000); } let xdexExportPollTimer = null; let xdexExportLastState = null; async function xdexExportRefreshState() { if (!xdexExportRuntimeAvailable()) return; try { const state = await xdexExportSend({ type: 'xdex:export:list' }); if (!state) return; xdexExportLastState = state; xdexExportRenderMiniList(state.tasks || []); xdexExportRenderDialogTasks(state.tasks || []); // 弹窗历史 = 任务表 history(与导出页「历史任务」同源),直接渲染 try { xdexExportRenderDialogHistory(state.history || []); } catch (e) { /* 忽略 */ } } catch (e) { /* 扩展上下文失效时静默 */ } } // 把弹窗里的串号元素接入引用体系: // 渲染为 <font color="#789922">No.xxx</font>(与原站引用号同构)并包进 .h-threads-content 容器, // 再对该容器调用 initExtendedContent —— 由此获得与串内引用一致的行为: // 悬停原生引用浮窗、点击展开拓展引用浮窗、可用性检测等。 function xdexExportAttachQuoteRef(el, tid) { if (!el) return; el.innerHTML = ''; const container = document.createElement('div'); container.className = 'h-threads-content'; container.style.display = 'inline'; const font = document.createElement('font'); font.setAttribute('color', '#789922'); font.textContent = `No.${tid}`; container.appendChild(font); el.appendChild(container); try { if (typeof initExtendedContent === 'function') initExtendedContent(el); else if (typeof extendQuote === 'function') extendQuote(el); } catch (e) { /* 引用功能不可用时静默(显示为纯文本链接色) */ } } // 弹窗内的任务队列:与缩略条同源(state.tasks 中 running/queued 的任务)。 // 头部一行呈现:左「任务队列」/ 中「当前任务 + 进度」/ 右「任务总数」,可折叠。 function xdexExportRenderDialogTasks(tasks) { const box = document.getElementById('xdex-export-dialog-tasks'); if (!box) return; const list = (tasks || []).filter((t) => t && (t.status === 'running' || t.status === 'queued')); const statusEl = document.getElementById('xdex-export-queue-status'); const countEl = document.getElementById('xdex-export-queue-count'); if (countEl) countEl.textContent = list.length ? `共 ${list.length} 个` : ''; if (statusEl) { const cur = list.find((t) => t.status === 'running') || list[0]; if (cur) { const { done, total } = xdexExportMiniPercent(cur); statusEl.textContent = `${xdexExportMiniTitle(cur)} ${done}/${total || '?'}`; } else { statusEl.textContent = '暂无进行中的任务'; } } if (!list.length) { box.textContent = '暂无进行中的任务'; box.className = 'xdex-export-dialog-tasks empty'; return; } box.className = 'xdex-export-dialog-tasks'; box.innerHTML = ''; list.forEach((task) => { const { percent } = xdexExportMiniPercent(task); const row = document.createElement('div'); row.className = 'xdex-export-dialog-task'; const head = document.createElement('div'); head.className = 'xdex-export-dialog-task-head'; const titleEl = document.createElement('span'); titleEl.className = 'xdex-export-dialog-task-title'; titleEl.textContent = xdexExportMiniTitle(task); titleEl.title = titleEl.textContent; const tidEl = document.createElement('span'); tidEl.className = 'xdex-export-dialog-task-tid'; xdexExportAttachQuoteRef(tidEl, task.tid); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'xdex-export-dialog-btn tiny'; btn.textContent = '中止'; btn.title = '取消任务(已下载的页会照常导出)'; btn.addEventListener('click', () => { xdexExportSend({ type: 'xdex:export:cancel', details: { id: task.id } }) .then(() => xdexExportRefreshState()) .catch(() => {}); }); head.appendChild(titleEl); head.appendChild(tidEl); head.appendChild(btn); const track = document.createElement('div'); track.className = 'xdex-export-dialog-task-track'; const fill = document.createElement('div'); fill.className = 'xdex-export-dialog-task-fill'; fill.style.width = `${percent}%`; track.appendChild(fill); const meta = document.createElement('div'); meta.className = 'xdex-export-dialog-task-meta'; meta.textContent = xdexExportMiniLabel(task); row.appendChild(head); row.appendChild(track); row.appendChild(meta); box.appendChild(row); }); } // ---- 提交弹窗 ---- // 弹窗状态:关闭后保留,重开时恢复(串号输入、格式、页码范围、只看PO、检索结果、状态文案) const xdexExportDialogState = { tid: '', format: 'md', from: '', to: '', poOnly: false, probed: new Map(), probeText: '', statusText: '', statusTone: '', queueExpanded: false, // 任务队列默认折叠(与历史记录一致) historyExpanded: false // 历史记录默认折叠,避免每次打开都占很大篇幅 }; // 弹窗内的历史记录:**直接复用任务表的 history**(与扩展导出页「历史任务」同一份数据), // 因此天然同步:任务完成自动出现、导出页删除/清空后弹窗下次刷新即一致。 // 展示为同款式(标题 + 串号 + 状态 + 删除按钮),默认折叠。 function xdexExportRenderDialogHistory(list) { const box = document.getElementById('xdex-export-dialog-history'); if (!box) return; const items = Array.isArray(list) ? list : []; const countEl = document.getElementById('xdex-export-history-count'); if (countEl) countEl.textContent = items.length ? `共 ${items.length} 条` : ''; box.className = 'xdex-export-dialog-tasks' + (items.length ? '' : ' empty'); if (!items.length) { box.textContent = '(暂无历史)'; return; } box.innerHTML = ''; const statusMap = { done: '已完成', failed: '失败', canceled: '已取消', interrupted: '已中断' }; items.forEach((task) => { const row = document.createElement('div'); row.className = 'xdex-export-dialog-task'; const head = document.createElement('div'); head.className = 'xdex-export-dialog-task-head'; const titleEl = document.createElement('span'); titleEl.className = 'xdex-export-dialog-task-title'; titleEl.textContent = xdexExportMiniTitle(task); titleEl.title = titleEl.textContent; const tidEl = document.createElement('span'); tidEl.className = 'xdex-export-dialog-task-tid'; xdexExportAttachQuoteRef(tidEl, task.tid); const del = document.createElement('button'); del.type = 'button'; del.className = 'xdex-export-dialog-btn tiny'; del.textContent = '删除'; del.title = '删除这条历史记录'; del.addEventListener('click', () => { // 与导出页「删除记录」同一条消息:任务表 history 删除后两处一致 xdexExportSend({ type: 'xdex:export:remove-history', details: { id: task.id } }) .then(() => xdexExportRefreshState()) .catch(() => {}); }); head.appendChild(titleEl); head.appendChild(tidEl); head.appendChild(del); const meta = document.createElement('div'); meta.className = 'xdex-export-dialog-task-meta'; const parts = [statusMap[task.status] || task.status || '']; if (task.filename) parts.push(task.filename); else if (task.error) parts.push(task.error); if (task.finishedAt) parts.push(new Date(task.finishedAt).toLocaleString()); meta.textContent = parts.filter(Boolean).join(' '); row.appendChild(head); row.appendChild(meta); box.appendChild(row); }); } function xdexExportOpenDialog(prefillTid) { if (!xdexExportRuntimeAvailable()) { toast('导出需安装扩展版 X岛-EX,或脚本猫桥接脚本', 2400, { type: 'error', key: 'export-needs-extension' }); return; } const tid = prefillTid || xdexExportCurrentTid(); if (document.getElementById('xdex-export-dialog')) return; const dialog = document.createElement('div'); dialog.id = 'xdex-export-dialog'; dialog.innerHTML = '' + '<div class="xdex-export-dialog-panel">' + ' <div class="xdex-export-dialog-head"><span>导出串</span>' + ' <span class="xdex-export-dialog-head-btns">' + ' <button type="button" class="xdex-export-dialog-icon" data-act="min" title="最小化">−</button>' + ' <button type="button" class="xdex-export-dialog-icon" data-act="close" title="关闭(任务继续)">×</button>' + ' </span></div>' + ' <div class="xdex-export-dialog-body">' + ' <div class="xdex-export-dialog-row">' + ` <input id="xdex-export-tid" type="text" placeholder="串号 / No.67024789 / 链接,可逗号批量" value="${xdexExportEscapeHtml(tid)}">` + ' <button type="button" class="xdex-export-dialog-btn" data-act="probe">检索</button>' + ' </div>' + ' <div id="xdex-export-probe" class="xdex-export-dialog-info"></div>' + ' <div class="xdex-export-dialog-row">' + ' <span class="xdex-export-dialog-label">总页码</span><span id="xdex-export-total" class="xdex-export-dialog-value">—</span>' + ' <span class="xdex-export-dialog-label">格式</span>' + ' <select id="xdex-export-format"><option value="md">Markdown</option><option value="txt">TXT</option></select>' + ' </div>' + ' <div class="xdex-export-dialog-row">' + ' <span class="xdex-export-dialog-label">下载</span>' + ' <input id="xdex-export-from" type="number" min="1" placeholder="起始" class="xdex-export-dialog-num">' + ' <span>-</span>' + ' <input id="xdex-export-to" type="number" min="1" placeholder="结束" class="xdex-export-dialog-num">' + ' <button type="button" class="xdex-export-dialog-btn primary" data-act="submit">开始</button>' + ' <label class="xdex-export-dialog-switch" for="xdex-export-po" title="只导出主串(PO)自己的发言">' + ' <input id="xdex-export-po" type="checkbox" class="xdex-switch" role="switch"><span>只看 PO</span></label>' + ' </div>' + ' <div id="xdex-export-status" class="xdex-export-dialog-info"></div>' + ' <div class="xdex-export-dialog-queue">' + ' <div class="xdex-export-dialog-queue-head" data-act="toggle-queue" role="button" tabindex="0">' + ' <span class="q-title">任务队列</span>' + ' <span class="q-status" id="xdex-export-queue-status">暂无进行中的任务</span>' + ' <span class="q-count" id="xdex-export-queue-count"></span>' + ' <span class="q-caret" id="xdex-export-queue-caret"></span>' + ' </div>' + ' <div id="xdex-export-dialog-tasks" class="xdex-export-dialog-tasks"></div>' + ' </div>' + ' <div class="xdex-export-dialog-queue collapsed" id="xdex-export-dialog-history-box">' + ' <div class="xdex-export-dialog-queue-head" data-act="toggle-history" role="button" tabindex="0">' + ' <span class="q-title">历史记录</span>' + ' <span class="q-count" id="xdex-export-history-count"></span>' + ' <span class="q-caret" id="xdex-export-history-caret"></span>' + ' </div>' + ' <div id="xdex-export-dialog-history" class="xdex-export-dialog-tasks"></div>' + ' </div>' + ' </div>' + '</div>'; document.body.appendChild(dialog); const q = (sel) => dialog.querySelector(sel); const status = (text, tone) => { const el = q('#xdex-export-status'); el.textContent = text || ''; el.dataset.tone = tone || ''; xdexExportDialogState.statusText = text || ''; xdexExportDialogState.statusTone = tone || ''; }; // 已检索通过的串号集合:跨弹窗开关复用(存在模块级状态里) const probed = xdexExportDialogState.probed; // 回填上次的输入与结果,避免每次打开都从零开始 try { const st = xdexExportDialogState; if (st.format) q('#xdex-export-format').value = st.format; if (st.from) q('#xdex-export-from').value = st.from; if (st.to) q('#xdex-export-to').value = st.to; q('#xdex-export-po').checked = !!st.poOnly; if (st.probeText) { q('#xdex-export-probe').textContent = st.probeText; } else { // 未检索前给一句说明,避免信息栏空着显得突兀 const ph = q('#xdex-export-probe'); ph.textContent = '填写串号后点「检索」,这里会显示每个串的标题、总页数与回复数(支持 No.67024789 / 链接 / 逗号批量)'; ph.dataset.tone = 'placeholder'; } if (st.statusText) status(st.statusText, st.statusTone); if (probed.size === 1) q('#xdex-export-total').textContent = String(Array.from(probed.values())[0].totalPages); else if (probed.size > 1) q('#xdex-export-total').textContent = `${probed.size} 个串`; xdexExportRenderDialogTasks(xdexExportLastState && xdexExportLastState.tasks); if (xdexExportExtensionAvailable()) { // 扩展环境:历史在任务表(chrome.storage),拉一次最新 xdexExportSend({ type: 'xdex:export:list' }).then((st) => { if (st) { xdexExportLastState = st; xdexExportRenderDialogHistory(st.history || []); } }).catch(() => {}); } else { // 脚本猫端:历史同样在后台任务表里。本页可能还没跑过 list(按需轮询是空的), // 所以也要主动取一次,否则「修改前」由其它页面/会话产生的记录看不到。 xdexExportSend({ type: 'xdex:export:list' }).then((st) => { if (st) { xdexExportLastState = st; xdexExportRenderDialogHistory(st.history || []); } }).catch(() => { // 后台未运行时退回最近一次已知状态 xdexExportRenderDialogHistory(xdexExportLastState && xdexExportLastState.history); }); } } catch (_e) { /* 忽略 */ } // 输入变化即写回状态 const rememberInputs = () => { const st = xdexExportDialogState; st.tid = q('#xdex-export-tid').value; st.format = q('#xdex-export-format').value; st.from = q('#xdex-export-from').value; st.to = q('#xdex-export-to').value; st.poOnly = q('#xdex-export-po').checked; }; ['#xdex-export-tid', '#xdex-export-format', '#xdex-export-from', '#xdex-export-to', '#xdex-export-po'].forEach((sel) => { const el = q(sel); if (!el) return; el.addEventListener('input', rememberInputs); el.addEventListener('change', rememberInputs); }); const closeDialog = () => { rememberInputs(); dialog.remove(); }; // 点击遮罩(面板以外区域)关闭 dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); }); // 任务队列折叠(参照设置面板 WebDAV 行的折叠交互) const queueBox = q('.xdex-export-dialog-queue'); const applyQueueExpanded = () => { if (!queueBox) return; const expanded = !!xdexExportDialogState.queueExpanded; queueBox.classList.toggle('collapsed', !expanded); // 与「常用串EX」同一个箭头函数:展开→向下,收起→向左 try { if (typeof setPostExpandToggleVisual === 'function') { setPostExpandToggleVisual(q('#xdex-export-queue-caret'), expanded); } } catch (_e) { /* 忽略 */ } }; if (queueBox) { q('[data-act="toggle-queue"]').addEventListener('click', () => { xdexExportDialogState.queueExpanded = !xdexExportDialogState.queueExpanded; applyQueueExpanded(); }); } applyQueueExpanded(); // 历史记录折叠(默认折叠;箭头与任务队列同款) const historyBox = q('#xdex-export-dialog-history-box'); const applyHistoryExpanded = () => { if (!historyBox) return; const expanded = !!xdexExportDialogState.historyExpanded; historyBox.classList.toggle('collapsed', !expanded); try { if (typeof setPostExpandToggleVisual === 'function') { setPostExpandToggleVisual(q('#xdex-export-history-caret'), expanded); } } catch (_e) { /* 忽略 */ } }; if (historyBox) { q('[data-act="toggle-history"]').addEventListener('click', () => { xdexExportDialogState.historyExpanded = !xdexExportDialogState.historyExpanded; applyHistoryExpanded(); }); } applyHistoryExpanded(); q('[data-act="close"]').addEventListener('click', closeDialog); q('[data-act="min"]').addEventListener('click', closeDialog); // 范围输入:留空即全部页(from=1, to=0) const readRange = () => { const from = Math.max(0, Number(q('#xdex-export-from').value) || 0); const to = Math.max(0, Number(q('#xdex-export-to').value) || 0); return { from: from || 1, to: to || 0 }; }; // 校验用户填写的页码区间是否真实存在(依据检索到的总页数)。 // 只填一端时另一端自动补全:只填起始 → 到最后一页;只填结束 → 从第 1 页。 const validateRange = (info) => { const rawFrom = q('#xdex-export-from').value.trim(); const rawTo = q('#xdex-export-to').value.trim(); const total = Number(info && info.totalPages) || 0; if (!rawFrom && !rawTo) return { ok: true, from: 1, to: 0, all: true }; const fromN = Number(rawFrom); const toN = Number(rawTo); if (rawFrom && (!Number.isFinite(fromN) || fromN < 1)) return { ok: false, error: '起始页必须是 ≥1 的整数' }; if (rawTo && (!Number.isFinite(toN) || toN < 1)) return { ok: false, error: '结束页必须是 ≥1 的整数' }; const from = rawFrom ? Math.floor(fromN) : 1; const to = rawTo ? Math.floor(toN) : (total || 0); if (total && from > total) return { ok: false, error: `起始页 ${from} 超出总页数 ${total}` }; if (total && to > total) return { ok: false, error: `结束页 ${to} 超出总页数 ${total}` }; if (to && to < from) return { ok: false, error: `结束页 ${to} 小于起始页 ${from}` }; return { ok: true, from, to, all: false }; }; q('[data-act="probe"]').addEventListener('click', async () => { const list = xdexExportParseTidList(q('#xdex-export-tid').value.trim() || xdexExportCurrentTid()); if (!list.length) { status('请输入串号', 'error'); return; } probed.clear(); status('正在检索…'); const oks = []; const fails = []; for (const item of list) { if (!item.ok) { fails.push(`${item.raw}:${item.error}`); continue; } try { const info = await xdexExportSend({ type: 'xdex:export:probe', details: { tid: item.tid } }); probed.set(item.tid, info); oks.push(`✓《${info.title || `串${item.tid}`}》 ${item.tid} 共 ${info.totalPages} 页 / ${info.totalReplies} 回复`); } catch (e) { fails.push(`✗ ${item.tid} 不可下载:${e.message}`); } } const probeEl = q('#xdex-export-probe'); probeEl.textContent = oks.concat(fails).join('\n'); probeEl.dataset.tone = fails.length ? 'error' : 'ok'; xdexExportDialogState.probeText = probeEl.textContent; if (probed.size === 1) { const only = Array.from(probed.values())[0]; q('#xdex-export-total').textContent = String(only.totalPages); } else { q('#xdex-export-total').textContent = probed.size ? `${probed.size} 个串` : '—'; } status(fails.length ? `${fails.length} 个串不可下载,其余可正常加入` : `检索完成:${oks.length} 个串可下载`, fails.length ? 'error' : 'ok'); }); q('[data-act="submit"]').addEventListener('click', async () => { const list = xdexExportParseTidList(q('#xdex-export-tid').value.trim() || xdexExportCurrentTid()); if (!list.length) { status('请输入串号', 'error'); return; } const bothEmpty = !q('#xdex-export-from').value.trim() && !q('#xdex-export-to').value.trim(); const format = q('#xdex-export-format').value; const poOnly = q('#xdex-export-po').checked; // 已经检索过的不再重复检索:只对「尚未检索」的串补检 const listOk = list.filter((x) => x.ok); const needProbe = listOk.filter((x) => !probed.get(x.tid)); status(needProbe.length ? `正在检索 ${needProbe.length} 个未检索的串…` : '正在提交…'); const done = []; const fails = []; const infos = []; for (const item of list) { if (!item.ok) { fails.push(`${item.raw}:${item.error}`); continue; } // 复用已有检索结果;没有才去检索 let info = probed.get(item.tid); if (!info) { try { info = await xdexExportSend({ type: 'xdex:export:probe', details: { tid: item.tid } }); probed.set(item.tid, info); } catch (e) { fails.push(`${item.tid} 不可下载:${e.message}`); continue; } } // 页码区间校验(依据该串的总页数) const chk = validateRange(info); if (!chk.ok) { fails.push(`${item.tid} 页码范围无效:${chk.error}`); continue; } if (bothEmpty || chk.all) { infos.push(`《${info.title || `串${item.tid}`}》 ${item.tid} 共 ${info.totalPages} 页 / ${info.totalReplies} 回复 → 全量导出`); } try { await xdexExportSend({ type: 'xdex:export:submit', details: { tid: item.tid, title: info.title || '', fid: info.fid || '', totalPages: info.totalPages || 0, from: chk.from, to: chk.to, format, poOnly } }); done.push(`${item.tid}(${chk.from}-${chk.all ? info.totalPages : chk.to} 页)`); } catch (e) { fails.push(`${item.tid} 提交失败:${e.message}`); } } const parts = []; if (infos.length) parts.push(infos.join('\n')); if (done.length) parts.push(`已加入导出队列 ${done.length} 个:${done.join('、')}`); if (fails.length) parts.push(`失败 ${fails.length} 个:${fails.join(';')}`); status(parts.join('|'), fails.length ? 'error' : 'ok'); if (infos.length) q('#xdex-export-probe').textContent = infos.join('\n'); xdexExportIdleTicks = 0; xdexExportEnsureProgressPolling(); await xdexExportRefreshState(); }); } // ---- 按钮 + 样式注入 ---- function xdexExportInstallUi() { if (!xdexExportRuntimeAvailable()) return; // 扩展与脚本猫桥接均不可用:不注入 if (document.getElementById('xdex-export-ui')) return; const style = document.createElement('style'); style.id = 'xdex-export-ui'; style.textContent = '' // 外观复用阅图模式按钮 .xdex-image-viewer-btn(+ 站点 active 类 ultimate-bold-correct), // 这里只覆盖定位到 sp_btn 正下方 + '#xdex-export-btn{top:54px;right:10px;}' + '#xdex-export-mini{position:fixed;top:96px;right:10px;z-index:10000;width:172px;padding:8px;border-radius:10px;' + 'background:rgba(255,255,238,.97);border:1px solid #bfa58f;box-shadow:0 2px 10px rgba(0,0,0,.18);display:none;' + 'font:12px/1.5 "Microsoft YaHei",sans-serif;color:#4a3a30;}' + '.xdex-export-mini-head{display:flex;align-items:center;gap:4px;}' + '.xdex-export-mini-tid{flex:1;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}' + '.xdex-export-mini-track{height:6px;border-radius:999px;background:rgba(143,74,47,.15);overflow:hidden;margin:5px 0 3px;}' + '.xdex-export-mini-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#8f4a2f,#1f6f74);transition:width .2s ease;}' + '.xdex-export-mini-label{color:#8a7566;text-align:right;}' + '.xdex-export-mini-title{font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:96px;}' // 展开列表:整块纵向排列(标题行 / 进度条 / 状态),每行之间留白 + '.xdex-export-mini-more{margin-top:6px;padding-top:6px;border-top:1px dashed #bfa58f;display:flex;flex-direction:column;gap:9px;max-height:46vh;overflow:auto;}' + '.xdex-export-mini-more-row{display:block;}' + '.xdex-export-mini-more-head{display:flex;align-items:center;gap:4px;}' + '.xdex-export-mini-more-title{flex:1;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}' + '.xdex-export-mini-more-tid{color:#8a7566;white-space:nowrap;font-size:11.5px;}' + '.xdex-export-mini-more-track{height:5px;border-radius:999px;background:rgba(143,74,47,.15);overflow:hidden;margin:4px 0 2px;}' + '.xdex-export-mini-more-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#8f4a2f,#1f6f74);transition:width .2s ease;}' + '.xdex-export-mini-more-label{color:#8a7566;text-align:right;font-size:11.5px;}' // 缩略条的展开/中止按钮:做成有边框的小胶囊,提升可点性与辨识度 + '.xdex-export-mini-btn{min-width:20px;height:18px;padding:0 5px;border:1px solid #bfa58f;border-radius:999px;background:#fffaf4;cursor:pointer;' + 'color:#683520;font:11px/1 "Microsoft YaHei",sans-serif;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;}' + '.xdex-export-mini-btn:hover{background:#f0e0d6;border-color:#8f4a2f;}' + '.xdex-export-mini-btn[data-act="cancel"]{color:#9d3026;border-color:#d9a79f;}' + '.xdex-export-mini-btn[data-act="cancel"]:hover{background:#f7e2df;border-color:#9d3026;}' + '.xdex-export-mini-btn[data-act="expand"]{font-weight:700;}' + '#xdex-export-dialog{position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,.35);display:flex;align-items:flex-start;justify-content:center;padding-top:12vh;}' + '.xdex-export-dialog-panel{width:min(680px,calc(100vw - 32px));background:#FFFFEE;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.28);overflow:hidden;font:14px/1.6 "Microsoft YaHei",sans-serif;color:#2b211c;}' + '.xdex-export-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#F0E0D6;font-weight:700;}' + '.xdex-export-dialog-head-btns{display:flex;gap:6px;}' + '.xdex-export-dialog-icon{border:none;background:transparent;cursor:pointer;font:16px/1 "Microsoft YaHei",sans-serif;color:#8a7566;padding:2px 6px;border-radius:6px;}' + '.xdex-export-dialog-icon:hover{background:rgba(0,0,0,.08);}' + '.xdex-export-dialog-body{padding:14px;display:flex;flex-direction:column;gap:10px;}' + '.xdex-export-dialog-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}' + '.xdex-export-dialog-row input[type=text]{flex:1;min-width:0;padding:6px 10px;border:1px solid #bfa58f;border-radius:8px;box-sizing:border-box;}' + '.xdex-export-dialog-row select{padding:6px 8px;border:1px solid #bfa58f;border-radius:8px;}' + '.xdex-export-dialog-num{width:76px;padding:6px 8px;border:1px solid #bfa58f;border-radius:8px;box-sizing:border-box;}' + '.xdex-export-dialog-btn{padding:6px 14px;border:1px solid #bfa58f;border-radius:999px;background:#fff;cursor:pointer;font-weight:700;color:#683520;}' + '.xdex-export-dialog-btn.primary{background:#8f4a2f;border-color:#683520;color:#fffaf4;}' + '.xdex-export-dialog-label{color:#8a7566;}' + '.xdex-export-dialog-value{font-weight:700;}' + '.xdex-export-dialog-info{min-height:18px;font-size:12px;color:#8a7566;white-space:pre-wrap;}' + '.xdex-export-dialog-info[data-tone=error]{color:#9d3026;}' + '.xdex-export-dialog-info[data-tone=ok]{color:#1f6f74;}' + '.xdex-export-dialog-info[data-tone=placeholder]{color:#a2917f;font-style:italic;}' + '.xdex-export-dialog-check{display:flex;align-items:center;gap:6px;color:#8a7566;font-size:13px;}' // 「只看 PO」直接沿用设置面板的 .xdex-switch 规则:选择器与声明逐字一致, // 且与面板一样不做暗色覆盖 —— 两处开关必须表现完全相同。 + '.xdex-switch { appearance:none; width:34px; height:18px; flex:0 0 34px; border:1px solid #a98f7a; border-radius:999px; background:#EE0000; cursor:pointer; position:relative; box-sizing:border-box; transition:background .18s ease,border-color .18s ease; }' + '.xdex-switch::before { content:""; position:absolute; width:14px; height:14px; left:1px; top:1px; border-radius:50%; background:#FFFFEE; box-shadow:0 1px 2px rgba(0,0,0,.24); transition:transform .18s ease; }' + '.xdex-switch:checked { background:#66CCFF; border-color:#7da6bf; }' + '.xdex-switch:checked::before { transform:translateX(16px); }' + '.xdex-switch:focus-visible { outline:2px solid #cc1105; outline-offset:2px; }' + '.xdex-switch:disabled { opacity:1; cursor:default; }' + '.xdex-export-dialog-switch{display:flex;align-items:center;gap:6px;color:#8a7566;font-size:13px;cursor:pointer;user-select:none;}' // 弹窗内任务队列 + '.xdex-export-dialog-queue{margin-top:2px;border-top:1px dashed #d9c8bb;padding-top:8px;}' // 队列头部:参照设置面板「WebDAV 备份/同步」行的排布(左标题 / 中状态 / 右计数与折叠) + '.xdex-export-dialog-queue-head{display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none;padding:2px 0;}' + '.xdex-export-dialog-queue-head .q-title{font-weight:700;}' + '.xdex-export-dialog-queue-head .q-status{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8a7566;font-size:12px;text-align:center;}' // 历史区没有 q-status,由 q-count 吃掉剩余空间并把箭头推到最右 + '.xdex-export-dialog-queue-head .q-count{color:#8a7566;font-size:12px;white-space:nowrap;margin-left:auto;}' + '.xdex-export-dialog-queue-head .q-caret{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:#8a7566;}' + '.xdex-export-dialog-queue-head:hover .q-caret{color:#683520;}' + '.xdex-export-dialog-queue-head .q-caret .xdex-icon-chevron{display:block;width:16px;height:16px;}' + '.xdex-export-dialog-tasks{display:flex;flex-direction:column;gap:8px;max-height:26vh;overflow:auto;margin-top:8px;}' + '.xdex-export-dialog-queue.collapsed .xdex-export-dialog-tasks{display:none;}' + '.xdex-export-dialog-tasks.empty{color:#a2917f;font-size:12px;display:flex;align-items:center;justify-content:center;padding:6px 0;}' + '.xdex-export-dialog-task{border:1px solid #e3d5c8;border-radius:8px;padding:6px 8px;background:#fffdf6;}' + '.xdex-export-dialog-task-head{display:flex;align-items:center;gap:6px;}' + '.xdex-export-dialog-task-title{flex:1;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}' + '.xdex-export-dialog-task-tid{color:#8a7566;font-size:12px;white-space:nowrap;}' + '.xdex-export-dialog-task-track{height:6px;border-radius:999px;background:rgba(143,74,47,.15);overflow:hidden;margin:5px 0 3px;}' + '.xdex-export-dialog-task-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#8f4a2f,#1f6f74);transition:width .2s ease;}' + '.xdex-export-dialog-task-meta{color:#8a7566;font-size:12px;text-align:right;}' + '.xdex-export-dialog-btn.tiny{padding:2px 8px;font-size:12px;}' // ── 暗色(DarkReader 激活或脚本自定义深色):逐项覆盖,避免 DR 反转导致配色错乱 ── + ':root.xdex-darkreader-active #xdex-export-mini{background:rgba(43,44,45,.97);border-color:#5a4d42;color:#d9d0d0;}' + ':root.xdex-darkreader-active .xdex-export-mini-label,' + ':root.xdex-darkreader-active .xdex-export-mini-more-tid,' + ':root.xdex-darkreader-active .xdex-export-mini-more-label{color:#b9aeb0;}' + ':root.xdex-darkreader-active .xdex-export-mini-track,' + ':root.xdex-darkreader-active .xdex-export-mini-more-track{background:rgba(255,255,255,.14);}' + ':root.xdex-darkreader-active .xdex-export-mini-btn{background:#3a3b3c;border-color:#5a4d42;color:#d9d0d0;}' + ':root.xdex-darkreader-active .xdex-export-mini-btn:hover{background:#4a4b4c;border-color:#00ffcc;color:#00ffcc;}' + ':root.xdex-darkreader-active .xdex-export-mini-btn[data-act="cancel"]{color:#e08a80;border-color:#7a4a44;}' + ':root.xdex-darkreader-active .xdex-export-mini-btn[data-act="cancel"]:hover{background:#5a2f2a;border-color:#EE0000;color:#ff9a90;}' + ':root.xdex-darkreader-active .xdex-export-mini-more{border-top-color:#5a4d42;}' + ':root.xdex-darkreader-active #xdex-export-dialog{background:rgba(0,0,0,.55);}' + ':root.xdex-darkreader-active .xdex-export-dialog-panel{background:#2b2c2d;color:#e6dede;color-scheme:dark;box-shadow:0 8px 30px rgba(0,0,0,.6);}' + ':root.xdex-darkreader-active .xdex-export-dialog-head{background:#3a2f28;color:#e6dede;}' + ':root.xdex-darkreader-active .xdex-export-dialog-row input[type=text],' + ':root.xdex-darkreader-active .xdex-export-dialog-row select,' + ':root.xdex-darkreader-active .xdex-export-dialog-num{background:#1f2021;border-color:#5a4d42;color:#e6dede;}' + ':root.xdex-darkreader-active .xdex-export-dialog-btn{background:#3a3b3c;border-color:#5a4d42;color:#d9d0d0;}' + ':root.xdex-darkreader-active .xdex-export-dialog-btn:hover{border-color:#00ffcc;color:#00ffcc;}' + ':root.xdex-darkreader-active .xdex-export-dialog-btn.primary{background:#7a3f28;border-color:#a05a3a;color:#fff5ef;}' + ':root.xdex-darkreader-active .xdex-export-dialog-icon{color:#b9aeb0;}' + ':root.xdex-darkreader-active .xdex-export-dialog-icon:hover{background:rgba(255,255,255,.12);}' + ':root.xdex-darkreader-active .xdex-export-dialog-label,' + ':root.xdex-darkreader-active .xdex-export-dialog-value,' + ':root.xdex-darkreader-active .xdex-export-dialog-info,' + ':root.xdex-darkreader-active .xdex-export-dialog-check,' + ':root.xdex-darkreader-active .xdex-export-dialog-switch{color:#b9aeb0;}' + ':root.xdex-darkreader-active .xdex-export-dialog-info[data-tone=error]{color:#ff9a90;}' + ':root.xdex-darkreader-active .xdex-export-dialog-info[data-tone=ok]{color:#5fd6b0;}' + ':root.xdex-darkreader-active .xdex-export-dialog-info[data-tone=placeholder]{color:#8a7f80;}' + ':root.xdex-darkreader-active .xdex-export-dialog-queue{border-top-color:#5a4d42;}' + ':root.xdex-darkreader-active .xdex-export-dialog-queue-head:hover .q-caret{color:#00ffcc;}' + ':root.xdex-darkreader-active .xdex-export-dialog-queue-head .q-status,' + ':root.xdex-darkreader-active .xdex-export-dialog-queue-head .q-count,' + ':root.xdex-darkreader-active .xdex-export-dialog-queue-head .q-caret,' + ':root.xdex-darkreader-active .xdex-export-dialog-subtitle,' + ':root.xdex-darkreader-active .xdex-export-dialog-tasks.empty,' + ':root.xdex-darkreader-active .xdex-export-dialog-task-tid,' + ':root.xdex-darkreader-active .xdex-export-dialog-task-meta{color:#b9aeb0;}' + ':root.xdex-darkreader-active .xdex-export-dialog-task{background:#232425;border-color:#3a3b3c;}' + ':root.xdex-darkreader-active .xdex-export-dialog-task-track{background:rgba(255,255,255,.14);}'; document.head.appendChild(style); // 复用阅图模式按钮样式(确保基础样式已注入) if (typeof ensureImageViewerButtonStyle === 'function') ensureImageViewerButtonStyle(); const btn = document.createElement('button'); btn.id = 'xdex-export-btn'; btn.type = 'button'; btn.className = 'xdex-image-viewer-btn ultimate-bold-correct'; btn.title = '导出串'; btn.setAttribute('aria-label', '导出串'); // 图标:折角文档 + 两条文字线("文档 + 内容"的语义), // 与阅图按钮同规范:不带 width/height,由 .xdex-image-viewer-btn svg 统一设为 18×18。 btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' + 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + '<path d="M14.5 2.5H6.5A2 2 0 0 0 4.5 4.5v15a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7.5z"/>' + '<path d="M14.5 2.5v5h5"/>' + '<path d="M8.5 12h7M8.5 16.5h4.5"/>' + '</svg>'; btn.addEventListener('click', () => xdexExportOpenDialog('')); document.body.appendChild(btn); const mini = document.createElement('div'); mini.id = 'xdex-export-mini'; document.body.appendChild(mini); // 进度订阅: // - 扩展环境由 chrome.storage.onChanged 驱动(实时),无需轮询; // - 脚本猫桥接没有存储事件转发,只能轮询兜底。 // 桥接下 submit 会在后台连续抓取(大串可达数分钟),1s 轮询会让后台同时处理 // 大量 list 请求,因此间隔放宽到 3s,且同一时刻只允许一个在途请求。 // 进度订阅: // - 扩展环境由 chrome.storage.onChanged 驱动(实时),无需轮询; // - 脚本猫桥接没有存储事件转发,只能轮询。 // 关键:**没有在跑的任务时不轮询** —— 轮询一轮要经桥接往返(多次跨脚本写入、 // 两个 800ms 兜底轮询、若干 DOM 事件),常驻空转在普通浏览时纯属浪费。 // 仅当用户在本页提交过任务后才启动,任务清空后自动停止。 if (!xdexExportExtensionAvailable()) { xdexExportEnsureProgressPolling(); xdexExportRefreshState(); // 立即探测一次:本页可能是中途打开的,后台可能已有任务 } else { // 扩展环境:任务表变化由 chrome.storage.onChanged 推送(原生机制,零轮询开销)。 // 此前注释声称由 onChanged 驱动,但从未订阅 —— 导致扩展端弹窗进度/历史不自动更新。 try { if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) { chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local' || !changes) return; const hit = Object.keys(changes).some((k) => k === 'xdex-export-tasks' || k === 'xdex-export-dialog-history'); if (hit) xdexExportRefreshState(); }); } } catch (e) { /* 忽略 */ } xdexExportRefreshState(); } } // 非扩展环境:保留调用点(分支留空,等待后续实现) function xdexExportInit() { try { // 启动诊断:便于在页面控制台确认当前装的是哪个版本、通道是否就绪 try { console.log('[X岛-EX 导出] 通道诊断', '扩展=' + xdexExportExtensionAvailable(), '桥接=' + !!xdexExportBridge(), '按需轮询=' + (typeof xdexExportEnsureProgressPolling === 'function'), '受理回执超时=' + (typeof XDEX_EXPORT_BRIDGE_ACK_TIMEOUT_MS === 'number')); } catch (_e) { /* 忽略 */ } if (xdexExportRuntimeAvailable()) { xdexExportInstallUi(); return; } // 桥接脚本与主脚本同为 document-start,加载顺序不确定: // 若桥接尚未就绪,则监听其就绪事件后补注入(已就绪的情况上面已处理) const onBridgeReady = () => { document.removeEventListener('xdex:export-bridge-ready', onBridgeReady); try { if (xdexExportRuntimeAvailable()) xdexExportInstallUi(); } catch (_e) {} }; document.addEventListener('xdex:export-bridge-ready', onBridgeReady); } catch (e) { console.warn('[X岛-EX] 导出功能初始化失败', e); } } /* -------------------------------------------------- * tag -1. 入口初始化 * -------------------------------------------------- */ // 注册设置面板依赖到 XDex Object.assign(XDex.ui, { buildFavoriteThreadRowHtml, buildSubscriptionFeedRowHtml, buildThreadCookieWhitelistRowHtml, buildBlockedKeywordGroupRowHtml, buildCookieGroupTwoFieldRowHtml, getBlockedKeywordPlaceholder, collectFavoriteThreadsFromPanel, collectSubscriptionFeedsFromPanel }); Object.assign(XDex.data, { markColors, parseDescAndListByLastColon, isValidDesc, isValidHexColor, normalizeHexColor, clampColorChannel, rgbToHex, hexToRgb, hsvToRgb, rgbToHsv, hexToHsv, hsvToHex, parseRgbColorString, formatRgbColor, getMarkedGroupEffectiveColor, isValidThreadId, normalizeFavoriteThreadInput, normalizeFavoriteThreads, normalizeThreadCookieWhitelistGroups, mergeThreadCookieWhitelistGroups, normalizeBlockedGroups, normalizeBlockedKeywordGroupValue, isBlockedKeywordRegexGroup, normalizeBlockedKeywordGroups, flattenBlockedKeywords, compileBlockedKeywordRegex, normalizeMarkedGroups, escapeBlockedKeywordInputToken, joinBlockedKeywordInputTokens, isThreadIdKeyword, isEightDigitKeyword }); Object.assign(XDex.update, { getUpdateCheckState, setUpdateCheckState, getDefaultUpdateCheckState, updateSettingsButtonBadge, clearFooterUpdateHighlight, flashFooterUpdateHighlight, renderUpdateLogDialog, openUpdateLogDialog, closeUpdateLogDialog, maybeShowPendingUpdateDialogOnPanelOpen, checkForDailyScriptUpdate, shouldShowPendingUpdateReminder, formatLocalDateKey, getNextNaturalCheckAt, compareVersionStrings }); window.addEventListener('load', () => { startupPerfDebug.mark('window.load.start', startupPerfDebug.summarizeRoot(document)); startupPerfDebug.measure('window.load.enableHDImageAndLayoutFix', () => enableHDImageAndLayoutFix(document), () => startupPerfDebug.summarizeRoot(document)); startupPerfDebug.mark('window.load.end', startupPerfDebug.summarizeRoot(document)); }); installEarlyStartupObserver(); installEarlyCriticalVisualPass(); installEarlyEnhanceIslandAutoTitle(); $(document).ready(() => { startupPerfDebug.mark('document.ready.start', startupPerfDebug.summarizeRoot(document)); if (isNmbSearchPage()) { initNmbSearchMobileThreadRedirector(); startupPerfDebug.mark('startup.nmbSearchMobileThreadRedirector', startupPerfDebug.summarizeRoot(document)); return; } SettingPanel.init(); const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); disableVerifyInputMemory(document); initReplyParamScrollTo(); //?page&r=链接定位到对应回复 initDisableAutoQuote(); //关闭引用:阻止URL中?r=参数自动插入引用号 replyQuicklyOnBoardPage(); //板块页快速回复模式切换 if (cfg.enableCookieSwitch) createCookieSwitcherUI(); //快捷切换饼干 // 串内饼干偏好初始化 if (cfg.enableCookieSwitch && cfg.enableCookieConfirm) { const _tid = PageType.getThreadId(false); const _initThreadId = _tid ? _tid.slice(0, 8) : ''; if (_initThreadId) { initThreadCookiePref(_initThreadId); injectCookieCheckSwitch(_initThreadId); } } injectImageViewerButton(); //阅图模式入口 injectThreadTombstoneMark(); //回收站墓碑标识 enablePaginationDuplication(!!cfg.enablePaginationDuplication); //页码栏拓展为 7 个;开关仅控制是否添加页首页码 if (cfg.disableWatermark) disableWatermark(); //关闭图片水印 if (cfg.updatePreviewCookie) updatePreviewCookieId(); //预览真实饼干 if (cfg.hideEmptyTitleEmail) {hideEmptyTitleAndEmail(); //隐藏无名氏/无标题/回复/发串/版规 Utils.collapse($('.h-forum-header'), '『版规』'); Utils.collapse($('form[action="/Home/Forum/doReplyThread.html"]'), '『回复』'); Utils.collapse($('form[action="/Home/Forum/doPostThread.html"]'), '『发串』'); } //折叠版规-发串-回复 //if (cfg.updateReplyNumbers) updateReplyNumbers(); //添加回复编号 if (cfg.enableSeamlessPaging) initSeamlessPaging(); //自动-手动无缝翻页 bindThreadHistoryLiveSync(); bindPostHistoryLiveSync(); if (cfg.enableUpdateCheck) { initializeUpdateReminderUI(); //检查更新UI状态初始化 scheduleDailyUpdateCheck(); //每日检查更新 } else { updateSettingsButtonBadge(getDefaultUpdateCheckState()); clearFooterUpdateHighlight(); if (window.__xdexUpdateCheckTimer) { clearTimeout(window.__xdexUpdateCheckTimer); window.__xdexUpdateCheckTimer = 0; } } // 顶栏刷新:ready 同步阶段再兜底一次(幂等),不必等 batch2 50ms 队列 try { if (typeof installOverrideTopImageClickEarly === 'function') installOverrideTopImageClickEarly(); else if (typeof overrideTopImageClick === 'function') overrideTopImageClick(); } catch (e) {} interceptReplyForm(); //拦截回复中间页 enhancePostFormLayout(); //发帖UI调整 autoSelectReportReason(); //举报理由默认选择“其他” if (cfg.toggleSidebar) toggleSidebar(); //侧边栏收起功能 renderFavoriteThreadsMenu(); //常用串 xdexExportInit(); //串内容导出(扩展后台任务) if (isThreadHistoryPageActive()) { recordCurrentThreadHistory(0, { reason: 'initial-load', countVisit: true }); installThreadHistoryReactivationTracking(true); } else { installThreadHistoryReactivationTracking(false); } startupPerfDebug.mark('document.ready.syncSetup.end', startupPerfDebug.summarizeRoot(document)); // 保存原始函数 const _initContent = window.initContent; // 将X岛原版https://www.nmbxd1.com/Public/Js/h.desktop.js中作用于引用的initContent函数支持我们新增的非标准引用格式 window.initContent = function(root) { // 先执行原始逻辑 if (typeof _initContent === 'function') { _initContent(root); } // 再执行扩展逻辑 if (root) initExtendedContent(root); }; deferStartupSteps([ { label: 'startup.batch1.enableHDImageAndLayoutFix', run: () => { if (cfg.enableHDImageAndLayoutFix) enableHDImageAndLayoutFix(document); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch1.enableHDImage', run: () => { if (cfg.enableHDImageAndLayoutFix) enableHDImage(document); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch1.applyImageHideMode', run: () => { if (cfg.enableImageHideMode) applyImageHideMode(cfg.applyImageHideMode || 'default', document); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch1.highlightPO', run: () => highlightPO(), meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch1.enablePostExpand', run: () => { enablePostExpand(document); try { bindBoardThreadRefreshButtons(document); } catch (e) {} }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch1.refreshFilterDisplay', run: () => refreshFilterDisplay(cfg), meta: () => startupPerfDebug.summarizeRoot(document) } ], 0, 'batch1-layout-filter-image'); deferStartupSteps([ { label: 'startup.batch2.ExternalImagePreview.init', run: () => { if (cfg.enableExternalImagePreview) ExternalImagePreview.init(); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.runLinkBlank', run: () => { if (cfg.enableLinkBlank) runLinkBlank(); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.runAutoUrlLinkify', run: () => { if (cfg.enableAutoUrlLinkify) runAutoUrlLinkify(); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.enableQuotePreview', run: () => { if (cfg.enableQuotePreview) enableQuotePreview(); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.replaceRightSidebar', run: () => replaceRightSidebar(), meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.extendQuote', run: () => { if (cfg.extendQuote) extendQuote(); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.kaomojiEnhancer', run: () => kaomojiEnhancer(), meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.initExtendedContent', run: () => initExtendedContent(), meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.searchServiceBy4sY', run: () => searchServiceBy4sY(), meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.monitorRefView', run: () => monitorRefView(), meta: () => startupPerfDebug.summarizeRoot(document) }, // overrideTopImageClick 已提前到 early/ready 同步阶段;这里仅作幂等兜底 { label: 'startup.batch2.overrideTopImageClick', run: () => overrideTopImageClick(), meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.enhanceIsland', run: () => enhanceIsland({ enablePreview: true, enableDraft: true, enableAutoTitle: true, enableRelativeTime: true, timeDisplayMode: cfg.timeDisplayMode, enableQuoteInsert: true, enablePasteImage: true }), meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.initContent(document)', run: () => initContent(document), meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.installCookieDropdownShortcutHandler', run: () => { if (cfg.enableCookieSwitch) installCookieDropdownShortcutHandler(); }, meta: () => startupPerfDebug.summarizeRoot(document) }, { label: 'startup.batch2.bindCtrlEnter', run: () => document.querySelectorAll('form textarea[name="content"]').forEach(bindCtrlEnter), meta: () => startupPerfDebug.summarizeRoot(document) } ], 50, 'batch2-preview-content'); // 屏蔽原站点的 initImageBox,改由 enableHDImageAndLayoutFix 负责初始化 // window.initImageBox = function() { // // 屏蔽原站点的逻辑 // console.debug("initImageBox 已被屏蔽,由 enableHDImageAndLayoutFix 接管"); // }; // 自动刷新并提示当前饼干,我不知道为什么必须写在这里。 function autoRefreshCookiesToast() { try { if (typeof refreshCookies !== 'function') return; refreshCookies(() => { const list = getCookiesList(); if (!list || !Object.keys(list).length) return; const cur = getCurrentCookie(); const nm = cur && cur.name ? abbreviateName(cur.name) : '未知'; if (cfg.enableAutoCookieRefreshToast) { toast(`自动刷新成功!当前饼干为 ${nm}`); } }, false); // ★ 不显示默认toast } catch (e) { console.warn('自动刷新饼干失败', e); } } // 仅在“从其它标签页切回到本标签页”时刷新 const TAB_ACTIVE_KEY = 'xdex_active_tab'; const tabId = Date.now() + '_' + Math.random().toString(36).slice(2); try { if (document.visibilityState === 'visible') { localStorage.setItem(TAB_ACTIVE_KEY, JSON.stringify({ id: tabId, t: Date.now() })); } } catch {} // ✅ 加上开关判断 if (cfg.enableAutoCookieRefresh) { document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; try { const last = JSON.parse(localStorage.getItem(TAB_ACTIVE_KEY) || 'null'); if (last && last.id && last.id !== tabId) { autoRefreshCookiesToast(); } localStorage.setItem(TAB_ACTIVE_KEY, JSON.stringify({ id: tabId, t: Date.now() })); } catch (e) { try { localStorage.setItem(TAB_ACTIVE_KEY, JSON.stringify({ id: tabId, t: Date.now() })); } catch {} } }, { passive: true }); } startupPerfDebug.mark('document.ready.end', startupPerfDebug.summarizeRoot(document)); // 全局:在运行时立即应用“全部展开 / 全部收起”模式 window.applyPostExpandAllMode = function(enable) { const threads = Array.from(document.querySelectorAll('.h-threads-item-index')); document.documentElement.classList.toggle('xdex-post-expand-all', !!enable); if (!threads.length) return; if (enable) { // 全部展开:只切根节点 class;按钮文字分帧更新,避免同步逐串改 expanded 堵塞版块页。 threads.forEach(item => item.classList.remove('xdex-post-expand-collapsed')); let idx = 0; const updateButtons = () => { const end = Math.min(idx + 12, threads.length); for (; idx < end; idx++) { const btn = threads[idx].querySelector('.h-threads-info .js-toggle-mode'); if (btn) setPostExpandToggleVisual(btn, true); } if (idx < threads.length) requestAnimationFrame(updateButtons); }; requestAnimationFrame(updateButtons); } else { // 全部收起:取消根节点 class,并分批收起仍处于单串 expanded 的项目。 let delayIdx = 0; threads.forEach(item => { item.classList.remove('xdex-post-expand-collapsed'); const shouldCollapse = item.classList.contains('expanded'); const d = delayIdx++ * 45; // 45ms 间隔(经验值) setTimeout(() => { const btn = item.querySelector('.h-threads-info .js-toggle-mode'); if (btn) setPostExpandToggleVisual(btn, false); if (!shouldCollapse) return; try { collapseWithoutShift(item); } catch (err) { // 防守 try { item.classList.remove('expanded'); } catch(e){} } }, d); }); } }; }); } scheduleXDexStartup(); })(jQuery);