// ==UserScript== // @name 豆包用户消息气泡导航 // @namespace https://github.com/yourname/doubao-nav // @version 2.0.1.2 // @description 基于DOM结构,在豆包虚拟滚动技术下,为豆包网页版增加一个目录功能,更适合从0到n的新建对话。v2.1: sessionStorage 持久化、精准 scroller 跳转、快速扫描、总数估算。 // @author 你 // @match https://www.doubao.com/* // @grant none // @tag 导航 // @tag 目录 // @tag 工具 // @tag 豆包 // ==/UserScript== (function () { 'use strict'; // ============================================================ // 0. 常量 // ============================================================ const SCROLLER_SEL = '.v_list_scroller-BxcoIX'; const FALLBACK_SCROLLER_SEL = '.scrollable-Se7zNt'; const SCROLL_HOLDER_SEL = '.scroll_holder'; const V_LIST_ROW_SEL = '.v_list_row[data-observe-row]'; const USER_BUBBLE_SEL = 'div[class*="send-msg-bubble"]'; const STORAGE_PREFIX = 'db_nav_v10_'; const EST_ROW_HEIGHT = 120; // 估计平均每条消息的渲染高度(px) // ============================================================ // 1. 样式 — 完全对齐稳定版 // ============================================================ const CSS = ` .usr-nav-native { position: fixed; right: 10px; top: 50%; transform: translateY(-50%); z-index: 10; pointer-events: none; } .usr-nav-indicator { width: 16px; transition: width 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.3s ease, box-shadow 0.3s ease, backdrop-filter 0.3s ease; overflow: hidden; background: transparent; backdrop-filter: none; box-shadow: none; border-radius: 10px 0 0 10px; display: flex; flex-direction: column; align-items: stretch; max-height: 288px; cursor: pointer; pointer-events: auto; scroll-behavior: smooth; border-left: 0.5px solid rgba(0,0,0,0.04); } .usr-nav-indicator:hover { width: 260px; overflow-y: auto; background: linear-gradient(135deg, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0.35) 100%); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); box-shadow: -4px 0 24px rgba(0,0,0,0.06), 0 0 0 0.5px rgba(255,255,255,0.6) inset; } .usr-nav-item { height: 40px; width: 100%; position: relative; display: flex; align-items: center; flex-shrink: 0; padding-left: 6px; box-sizing: border-box; border-radius: 6px; transition: background 0.15s ease, border-radius 0.15s ease; } .usr-nav-dash-marker { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); width: 10px; height: 3px; border-radius: 3px; background: var(--s-color-text-tertiary, rgba(100, 110, 125, 0.55)); transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); z-index: 2; } .usr-nav-item.active .usr-nav-dash-marker { width: 14px; height: 4px; background: linear-gradient(90deg, #165DFF, #4080FF); box-shadow: 0 0 6px rgba(22,93,255,0.35); border-radius: 3px; } .usr-nav-item .usr-nav-text { display: none; } .usr-nav-indicator:hover .usr-nav-text { display: block; } .usr-nav-text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 400; line-height: 1.5; color: color-mix(in srgb, var(--s-color-text-primary, #1d1d1f) 45%, transparent); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 16px; margin-left: 6px; transition: color 0.15s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); transform: translateX(-4px); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .usr-nav-indicator:hover .usr-nav-text { transform: translateX(0); } .usr-nav-indicator:hover .usr-nav-item.active .usr-nav-text { color: #165DFF; font-weight: 500; } .usr-nav-indicator:hover .usr-nav-item:hover:not(.active) { border-radius: 8px; } .usr-nav-indicator:hover .usr-nav-item:hover:not(.active) .usr-nav-text { color: var(--s-color-text-primary, #1d1d1f); } .usr-nav-indicator:hover .usr-nav-item:hover .usr-nav-dash-marker { background: rgba(22,93,255,0.4); width: 12px; height: 3.5px; } .usr-nav-indicator::-webkit-scrollbar { width: 3px; } .usr-nav-indicator::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 3px; } .usr-nav-indicator::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.2); } .usr-nav-indicator::-webkit-scrollbar-track { background: transparent; } /* 扫描按钮 */ .usr-nav-scan-btn { display: none; height: 28px; width: 100%; flex-shrink: 0; align-items: center; justify-content: center; font-size: 12px; font-weight: 500; cursor: pointer; color: #165DFF; background: rgba(22,93,255,0.06); border-radius: 6px; margin-bottom: 4px; transition: background 0.15s ease; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; } .usr-nav-indicator:hover .usr-nav-scan-btn { display: flex; } .usr-nav-scan-btn:hover { background: rgba(22,93,255,0.12); } .usr-nav-scan-btn.scanning { color: #FF9500; background: rgba(255,149,0,0.08); pointer-events: none; } /* 扫描进度条 */ .usr-nav-scan-progress { display: none; height: 2px; width: 100%; flex-shrink: 0; background: rgba(0,0,0,0.06); border-radius: 1px; margin-bottom: 4px; overflow: hidden; } .usr-nav-scan-progress.active { display: block; } .usr-nav-scan-progress .fill { height: 100%; width: 0%; background: linear-gradient(90deg, #165DFF, #4080FF); border-radius: 1px; transition: width 0.3s ease; } /* 底部统计 */ .usr-nav-footer { display: none; flex-shrink: 0; font-size: 11px; color: var(--s-color-text-quaternary, rgba(0,0,0,0.3)); text-align: center; padding: 4px 8px 8px 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; white-space: nowrap; } .usr-nav-indicator:hover .usr-nav-footer { display: block; } `; document.head.appendChild(Object.assign(document.createElement('style'), { textContent: CSS })); // ============================================================ // 2. DOM 工具 // ============================================================ let indicator = null; function getScroller() { return document.querySelector(SCROLLER_SEL) || document.querySelector(FALLBACK_SCROLLER_SEL); } function getScrollHolder() { const s = getScroller(); return s ? s.querySelector(SCROLL_HOLDER_SEL) : null; } function getMessageId(bubble) { const msgEl = bubble.closest('[data-message-id]'); return msgEl ? msgEl.dataset.messageId : null; } function getCurrentBubbles() { const bubbles = document.querySelectorAll(USER_BUBBLE_SEL); const result = []; bubbles.forEach(b => { const id = getMessageId(b); if (id) result.push({ id, bubble: b }); }); return result; } /** * 读取当前已渲染的 v_list_row,收集其 --vlist-row-transform-y 作为定位锚点 * 返回 [{ id: messageId, y: px }, ...],按 y 升序 */ function getVisibleAnchors() { const rows = document.querySelectorAll(V_LIST_ROW_SEL); const anchors = []; rows.forEach(row => { const observeData = row.dataset.observeRow; // "block_12345" if (!observeData || !observeData.startsWith('block_')) return; const msgId = observeData.slice(6); // 去掉 "block_" 前缀 const y = getComputedStyle(row).getPropertyValue('--vlist-row-transform-y').trim(); const py = parseFloat(y); if (!isNaN(py)) { anchors.push({ id: msgId, y: py }); } }); anchors.sort((a, b) => a.y - b.y); return anchors; } /** * 根据已知锚点,用线性插值估算 targetId 的 scrollTop * 返回 null 表示无法估算 */ function estimateScrollTop(targetId, p) { const anchors = getVisibleAnchors(); if (anchors.length === 0) return null; const targetIdx = p.idOrder.indexOf(targetId); if (targetIdx < 0) return null; // 按 idOrder 索引排序锚点 const indexed = anchors .map(a => ({ ...a, idx: p.idOrder.indexOf(a.id) })) .filter(a => a.idx >= 0) .sort((a, b) => a.idx - b.idx); if (indexed.length === 0) return null; // 找包围 targetIdx 的两个锚点 let before = null, after = null; for (const a of indexed) { if (a.idx <= targetIdx) before = a; if (a.idx >= targetIdx && !after) after = a; } if (before && after && before.idx !== after.idx) { // 线性插值 const ratio = (targetIdx - before.idx) / (after.idx - before.idx); return before.y + (after.y - before.y) * ratio; } else if (before && before.idx === targetIdx) { return before.y; } else if (before) { // 目标在所有锚点之后:外推 return before.y + EST_ROW_HEIGHT * (targetIdx - before.idx); } else if (after) { // 目标在所有锚点之前:外推 return Math.max(0, after.y - EST_ROW_HEIGHT * (after.idx - targetIdx)); } return null; } /** * 获取 scroller 当前可视区域的中心对应的 scrollTop */ function getViewCenterScrollTop() { const s = getScroller(); if (!s) return 0; return s.scrollTop + s.clientHeight / 2; } // ============================================================ // 2b. IntersectionObserver — 高亮跟踪主要机制(对齐稳定版) // ============================================================ const io = new IntersectionObserver((entries) => { let bestId = null, bestRatio = 0; entries.forEach(e => { if (e.isIntersecting && e.intersectionRatio > bestRatio) { bestRatio = e.intersectionRatio; const id = getMessageId(e.target); if (id) bestId = id; } }); if (bestId && bestId !== activeId) { setActive(bestId); } }, { threshold: [0, 0.2, 0.5, 1.0] }); function observeBubbles() { io.disconnect(); document.querySelectorAll(USER_BUBBLE_SEL).forEach(b => { if (getMessageId(b)) io.observe(b); }); } // ============================================================ // 3. 多会话存储池 — 按 conversation_id 分池,切换对话自动换池 // v2.1: 集成 sessionStorage 持久化 // ============================================================ function getConversationId() { const match = window.location.pathname.match(/^\/chat\/(\d+)/); return match ? match[1] : null; } const pools = new Map(); // convId → { registry: Map, idOrder: [], activeId: null } let currentConvId = null; let activeId = null; // 当前池的激活消息 ID let switchCooldown = 0; // 对话切换后 1.5s 冷却期,防止轮询读到旧 DOM let savePending = false; // 防抖保存标记 function savePool(convId, p) { try { const data = { idOrder: p.idOrder.slice(), registry: {} }; p.registry.forEach((entry, id) => { data.registry[id] = { label: entry.label }; }); sessionStorage.setItem(STORAGE_PREFIX + convId, JSON.stringify(data)); } catch (e) { // sessionStorage 满或不可用,静默失败 } } function schedulePoolSave() { if (savePending) return; savePending = true; setTimeout(() => { savePending = false; const convId = getConversationId() || '__default__'; const p = pools.get(convId); if (p) savePool(convId, p); }, 500); } function loadPool(convId) { try { const raw = sessionStorage.getItem(STORAGE_PREFIX + convId); if (!raw) return null; const data = JSON.parse(raw); if (!data.idOrder || !data.registry) return null; const registry = new Map(); Object.entries(data.registry).forEach(([id, entry]) => { registry.set(id, { label: entry.label, row: null }); }); return { registry, idOrder: data.idOrder, activeId: null }; } catch (e) { return null; } } function switchToConversation(convId) { if (convId === currentConvId) return; // 先保存当前池 if (currentConvId) { const oldP = pools.get(currentConvId); if (oldP) savePool(currentConvId, oldP); } // 尝试从 sessionStorage 恢复 const restored = loadPool(convId); if (restored) { pools.set(convId, restored); currentConvId = convId; activeId = restored.activeId || null; rebuildPanel(); updateEstimatedCount(); switchCooldown = Date.now() + 1500; return; } // 新建空池 currentConvId = convId; pools.set(convId, { registry: new Map(), idOrder: [], activeId: null }); activeId = null; if (indicator) { indicator.querySelectorAll('.usr-nav-item, .usr-nav-scan-btn, .usr-nav-scan-progress, .usr-nav-footer').forEach(el => { if (!el.classList.contains('usr-nav-scan-btn') && !el.classList.contains('usr-nav-scan-progress') && !el.classList.contains('usr-nav-footer')) { el.remove(); } }); // 保留扫描按钮和底部统计(innerHTML 重置会丢),改为清空 nav items const items = indicator.querySelectorAll('.usr-nav-item'); items.forEach(el => el.remove()); } switchCooldown = Date.now() + 1500; // 冷却 1.5s,等 React 更新 DOM } function pool() { const convId = getConversationId() || '__default__'; if (convId !== currentConvId) { switchToConversation(convId); } return pools.get(currentConvId); } // 将 visible 中新发现的 ID 按 DOM-relative 位置插入 idOrder // DOM 顺序即时间序(聊天页面保证),比 BigInt 排序更可靠 function mergeNewIds(p, visible) { let added = false; visible.forEach(({ id }, i) => { if (p.idOrder.includes(id)) return; // 找 DOM 中左侧最近的已知邻居 let pred = null; for (let j = i - 1; j >= 0; j--) { if (p.idOrder.includes(visible[j].id)) { pred = visible[j].id; break; } } // 找 DOM 中右侧最近的已知邻居 let succ = null; for (let j = i + 1; j < visible.length; j++) { if (p.idOrder.includes(visible[j].id)) { succ = visible[j].id; break; } } if (pred) { p.idOrder.splice(p.idOrder.indexOf(pred) + 1, 0, id); } else if (succ) { p.idOrder.splice(p.idOrder.indexOf(succ), 0, id); } else { p.idOrder.push(id); } added = true; }); return added; } function ingest() { if (Date.now() < switchCooldown) return; // 对话切换冷却期,等 React 更新 DOM const p = pool(); if (!p) return; const visible = getCurrentBubbles(); // 先注册所有可见 ID(不插入 idOrder,由 mergeNewIds 统一处理) visible.forEach(({ id, bubble }) => { const label = bubble.textContent.trim().slice(0, 22); if (p.registry.has(id)) { p.registry.get(id).label = label; } else { p.registry.set(id, { label, row: null }); } }); // 按 DOM 相对位置插入新 ID const changed = mergeNewIds(p, visible); if (changed) { rebuildPanel(); schedulePoolSave(); } else { updateLabelsInPlace(); } updateActiveByScroll(); updateEstimatedCount(); observeBubbles(); } // ============================================================ // 4. 面板构建 — 增量复用 DOM,仅在顺序变化时重建 // ============================================================ function createNavItem(id, label, index) { const item = document.createElement('div'); item.className = 'usr-nav-item'; item.dataset.messageId = id; const marker = document.createElement('span'); marker.className = 'usr-nav-dash-marker'; const text = document.createElement('span'); text.className = 'usr-nav-text'; text.textContent = `${index + 1}. ${label}`; item.appendChild(marker); item.appendChild(text); return item; } function rebuildPanel() { const p = pool(); if (!p || !indicator) return; const oldMap = new Map(); indicator.querySelectorAll('.usr-nav-item').forEach(el => { oldMap.set(el.dataset.messageId, el); }); // 先移除旧 items(保留 scan-btn、scan-progress、footer) const scanBtn = indicator.querySelector('.usr-nav-scan-btn'); const scanProg = indicator.querySelector('.usr-nav-scan-progress'); const footer = indicator.querySelector('.usr-nav-footer'); const frag = document.createDocumentFragment(); // 确保扫描按钮在最前 if (scanBtn) frag.appendChild(scanBtn); if (scanProg) frag.appendChild(scanProg); p.idOrder.forEach((id, i) => { const entry = p.registry.get(id); if (!entry) return; let item = oldMap.get(id); if (item) { oldMap.delete(id); } else { item = createNavItem(id, entry.label, i); } entry.row = item; const textEl = item.querySelector('.usr-nav-text'); if (textEl) textEl.textContent = `${i + 1}. ${entry.label}`; frag.appendChild(item); }); oldMap.forEach(el => el.remove()); // 确保 footer 在最后 if (footer) frag.appendChild(footer); indicator.innerHTML = ''; indicator.appendChild(frag); // 重建后重新挂载 active 类(DOM 已全新,旧类丢失) if (activeId) { const cur = indicator.querySelector(`.usr-nav-item[data-message-id="${activeId}"]`); if (cur) cur.classList.add('active'); } updateEstimatedCount(); } function updateLabelsInPlace() { const p = pool(); if (!p) return; const items = indicator.querySelectorAll('.usr-nav-item'); items.forEach((item) => { const id = item.dataset.messageId; const entry = p.registry.get(id); if (!entry) return; entry.row = item; const idx = p.idOrder.indexOf(id); if (idx < 0) return; const textEl = item.querySelector('.usr-nav-text'); if (textEl) textEl.textContent = `${idx + 1}. ${entry.label}`; }); } // ============================================================ // 5. 激活高亮(lastActiveId 模式) // ============================================================ function updateActiveByScroll() { const visible = getCurrentBubbles(); if (visible.length === 0) return; const scroller = getScroller(); const viewCenter = scroller ? scroller.scrollTop + scroller.clientHeight / 2 : window.innerHeight / 2; let bestId = null, bestDist = Infinity; visible.forEach(({ id, bubble }) => { const s = getScroller(); let bubbleCenter; if (s) { const sRect = s.getBoundingClientRect(); const bRect = bubble.getBoundingClientRect(); bubbleCenter = s.scrollTop + (bRect.top - sRect.top) + bRect.height / 2; } else { const rect = bubble.getBoundingClientRect(); bubbleCenter = rect.top + rect.height / 2; } if (bubble.getBoundingClientRect().height === 0) return; const dist = Math.abs(bubbleCenter - viewCenter); if (dist < bestDist) { bestDist = dist; bestId = id; } }); if (bestId && bestId !== activeId) { setActive(bestId); } } function setActive(id) { if (activeId) { const prev = indicator.querySelector(`.usr-nav-item[data-message-id="${activeId}"]`); if (prev) prev.classList.remove('active'); } if (id) { const next = indicator.querySelector(`.usr-nav-item[data-message-id="${id}"]`); if (next) { next.classList.add('active'); setTimeout(() => { next.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 10); activeId = id; return; } } activeId = null; } // ============================================================ // 6. 跳转 — v2.1: 使用 scroller + 锚点估算替代 window.scrollBy // ============================================================ function scrollToMessage(id) { const scroller = getScroller(); const visible = getCurrentBubbles(); const match = visible.find(b => b.id === id); if (match) { // 目标在 DOM:直接用 scroller 滚动到目标 if (scroller) { const sRect = scroller.getBoundingClientRect(); const bRect = match.bubble.getBoundingClientRect(); const targetScroll = scroller.scrollTop + (bRect.top - sRect.top) - scroller.clientHeight / 2 + bRect.height / 2; scroller.scrollTo({ top: targetScroll, behavior: 'smooth' }); } else { match.bubble.scrollIntoView({ behavior: 'smooth', block: 'center' }); } match.bubble.style.transition = 'box-shadow 0.2s'; match.bubble.style.boxShadow = '0 0 0 3px rgba(22,93,255,0.4)'; setTimeout(() => { match.bubble.style.boxShadow = ''; }, 1500); return; } // 虚拟滚动:目标不在当前 DOM,用锚点估算位置直接跳转 const p = pool(); if (!p) return; const targetIdx = p.idOrder.indexOf(id); if (targetIdx < 0) return; const estimated = estimateScrollTop(id, p); if (estimated !== null && scroller) { // 用估算位置直接跳转 scroller.scrollTo({ top: estimated, behavior: 'smooth' }); // 跳转后等待虚拟列表渲染,尝试一次微调 setTimeout(() => { const retry = getCurrentBubbles().find(b => b.id === id); if (retry && scroller) { const sRect = scroller.getBoundingClientRect(); const bRect = retry.bubble.getBoundingClientRect(); const targetScroll = scroller.scrollTop + (bRect.top - sRect.top) - scroller.clientHeight / 2 + bRect.height / 2; scroller.scrollTo({ top: targetScroll, behavior: 'smooth' }); retry.bubble.style.transition = 'box-shadow 0.2s'; retry.bubble.style.boxShadow = '0 0 0 3px rgba(22,93,255,0.4)'; setTimeout(() => { retry.bubble.style.boxShadow = ''; }, 1500); } }, 350); return; } // 回退:方向性滚动(使用 scroller 而非 window) const visibleIds = visible.map(b => b.id); if (visibleIds.length === 0) return; const visFirst = p.idOrder.indexOf(visibleIds[0]); const visLast = p.idOrder.indexOf(visibleIds[visibleIds.length - 1]); if (targetIdx < visFirst) { if (scroller) { scroller.scrollBy({ top: -(scroller.clientHeight * 0.7), behavior: 'smooth' }); } else { window.scrollBy(0, -(window.innerHeight * 0.7)); } } else if (targetIdx > visLast) { if (scroller) { scroller.scrollBy({ top: scroller.clientHeight * 0.7, behavior: 'smooth' }); } else { window.scrollBy(0, window.innerHeight * 0.7); } } else { return; } setTimeout(() => scrollToMessage(id), 400); } // ============================================================ // 7. 快速扫描模式 — v2.1 新增 // ============================================================ let scanActive = false; let scanAbortController = null; async function startScan() { if (scanActive) return; scanActive = true; const scroller = getScroller(); if (!scroller) { scanActive = false; return; } const scrollHolder = getScrollHolder(); if (!scrollHolder) { scanActive = false; return; } const totalY = parseFloat( getComputedStyle(scrollHolder).getPropertyValue('--vlist-row-transform-y').trim() ); if (!totalY || totalY <= 0) { scanActive = false; return; } const viewHeight = scroller.clientHeight; const stepSize = viewHeight * 1.5; const totalSteps = Math.ceil(totalY / stepSize); // 更新按钮状态 const btn = indicator.querySelector('.usr-nav-scan-btn'); const progBar = indicator.querySelector('.usr-nav-scan-progress'); const progFill = progBar ? progBar.querySelector('.fill') : null; if (btn) btn.classList.add('scanning'); if (progBar) progBar.classList.add('active'); let currentStep = 0; return new Promise((resolve) => { scanAbortController = resolve; function step() { if (!scanActive) { // 扫描被中止 if (btn) btn.classList.remove('scanning'); if (progBar) progBar.classList.remove('active'); if (progFill) progFill.style.width = '0%'; return; } currentStep++; const pos = Math.min(currentStep * stepSize, totalY); // 更新进度 const pct = Math.min(100, Math.round((pos / totalY) * 100)); if (progFill) progFill.style.width = pct + '%'; if (btn) btn.textContent = `扫描中 ${pct}%`; if (pos >= totalY) { // 扫描完成 scanActive = false; if (btn) { btn.classList.remove('scanning'); btn.style.display = 'none'; } if (progBar) progBar.classList.remove('active'); if (progFill) progFill.style.width = '0%'; scheduleSync(); updateEstimatedCount(); return; } scroller.scrollTop = pos; // 等待虚拟列表渲染 + 同步 setTimeout(() => { scheduleSync(); setTimeout(step, 150); }, 250); } step(); }); } function stopScan() { scanActive = false; if (scanAbortController) { scanAbortController(); scanAbortController = null; } const btn = indicator.querySelector('.usr-nav-scan-btn'); const progBar = indicator.querySelector('.usr-nav-scan-progress'); const progFill = progBar ? progBar.querySelector('.fill') : null; if (btn) { btn.classList.remove('scanning'); btn.textContent = '🔍 扫描目录'; } if (progBar) progBar.classList.remove('active'); if (progFill) progFill.style.width = '0%'; } function createScanButton() { const btn = document.createElement('div'); btn.className = 'usr-nav-scan-btn'; btn.textContent = '🔍 扫描目录'; btn.addEventListener('click', (e) => { e.stopPropagation(); if (scanActive) { stopScan(); } else { startScan(); } }); const progBar = document.createElement('div'); progBar.className = 'usr-nav-scan-progress'; const progFill = document.createElement('div'); progFill.className = 'fill'; progBar.appendChild(progFill); return { btn, progBar }; } // ============================================================ // 8. 消息总数估算 — v2.1 新增 // ============================================================ function updateEstimatedCount() { const footer = indicator.querySelector('.usr-nav-footer'); if (!footer) return; const p = pool(); if (!p) { footer.textContent = ''; return; } const discovered = p.idOrder.length; const scrollHolder = getScrollHolder(); let estimatedTotal = null; if (scrollHolder) { const totalY = parseFloat( getComputedStyle(scrollHolder).getPropertyValue('--vlist-row-transform-y').trim() ); if (totalY && totalY > 0) { estimatedTotal = Math.round(totalY / EST_ROW_HEIGHT); } } if (estimatedTotal !== null) { footer.textContent = `≈${estimatedTotal} 条消息 · 已发现 ${discovered} 条`; } else if (discovered > 0) { footer.textContent = `已发现 ${discovered} 条消息`; } else { footer.textContent = ''; } } function createFooter() { const footer = document.createElement('div'); footer.className = 'usr-nav-footer'; return footer; } // ============================================================ // 9. 事件系统 // ============================================================ let scrollTimer; let pending = false; function scheduleSync() { if (pending) return; pending = true; requestAnimationFrame(() => { ingest(); pending = false; }); } // ============================================================ // 10. 初始化 — v2.1: 使用 v_list_scroller 作为主 scroller // ============================================================ function watchUrlChanges() { const origPush = history.pushState; const origReplace = history.replaceState; function onUrlChange() { const newConvId = getConversationId() || '__default__'; if (newConvId !== currentConvId) { // 立即切池+清面板,但不主动 sync // DOM 更新后 MutationObserver 会自然触发 ingest,避免读到旧 DOM switchToConversation(newConvId); } } history.pushState = function (...args) { origPush.apply(this, args); onUrlChange(); }; history.replaceState = function (...args) { origReplace.apply(this, args); onUrlChange(); }; window.addEventListener('popstate', onUrlChange); } async function init() { for (let i = 0; i < 100; i++) { const hasChat = getScroller() || document.querySelector('.scrollable-Se7zNt') || document.querySelector('[data-message-id]'); if (hasChat) break; await new Promise(r => setTimeout(r, 300)); } const outer = document.createElement('div'); outer.className = 'usr-nav-native'; indicator = document.createElement('div'); indicator.className = 'usr-nav-indicator'; outer.appendChild(indicator); document.body.appendChild(outer); // 创建扫描按钮和进度条 const { btn: scanBtn, progBar } = createScanButton(); indicator.appendChild(scanBtn); indicator.appendChild(progBar); // 创建底部统计 const footer = createFooter(); indicator.appendChild(footer); // 事件委托:点击导航项 → 跳转到对应消息 indicator.addEventListener('click', (e) => { const item = e.target.closest('.usr-nav-item'); if (item && item.dataset.messageId) { scrollToMessage(item.dataset.messageId); } }); // 监听 SPA 路由切换(pushState / replaceState / popstate) watchUrlChanges(); ingest(); // 挂载 scroller 滚动监听 const scroller = getScroller(); if (scroller && !scroller._usrNavScrollBound) { scroller._usrNavScrollBound = true; scroller.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(updateActiveByScroll, 80); }, { passive: true }); } // 聊天容器 MutationObserver const chat = scroller || document.querySelector('.scrollable-Se7zNt') || document.querySelector('[data-message-id]')?.closest('div[class*="scroll"]'); if (chat) { const mo = new MutationObserver(() => scheduleSync()); mo.observe(chat, { childList: true, subtree: true }); // 如果 chat 不是 scroller 且有独立滚动,监听其 scroll if (chat !== scroller) { chat.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(updateActiveByScroll, 80); }, { passive: true }); } } // 兜底:容器重绑定 let currentChat = chat; const bodyMo = new MutationObserver(() => { const newScroller = getScroller(); const newChat = newScroller || document.querySelector('.scrollable-Se7zNt'); if (newChat && newChat !== currentChat) { currentChat = newChat; const mo = new MutationObserver(() => scheduleSync()); mo.observe(newChat, { childList: true, subtree: true }); if (!newChat._usrNavScrollBound) { newChat._usrNavScrollBound = true; newChat.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(updateActiveByScroll, 80); }, { passive: true }); } scheduleSync(); } }); bodyMo.observe(document.body, { childList: true, subtree: true }); // window scroll 回退 window.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(updateActiveByScroll, 80); }, { passive: true }); // 终极兜底:每 4 秒轮询一次,MutationObserver 失效时仍能同步 // scheduleSync 自带 rAF 防抖,ingest 无新消息时仅轻量更新,开销可忽略 setInterval(() => scheduleSync(), 4000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(init, 100)); } else { setTimeout(init, 100); } })();