// ==UserScript== // @name 妖火论坛-帖子列表页双栏预览 // @namespace https://github.com/WuSanJu/yaohuo-split-preview // @version 3.3.0 // @description 左边原帖列表,右边 iframe 预览;支持长按拖拽分栏、已读记录、上一帖/下一帖、新标签、导出右侧为PNG、清痕迹;重新打开列表页时按帖子ID恢复上次浏览帖子,并复用统一的左栏“加载更多”逻辑实现:左栏触底自动追加 + 恢复定位时自动追加查找;新增 Ctrl+Shift:左侧回到最顶部并打开第一个帖子 // @author 巫山居 // @license MIT // @match *://yaohuo.me/bbs/book_list.aspx* // @match *://www.yaohuo.me/bbs/book_list.aspx* // @homepageURL https://github.com/WuSanJu/yaohuo-split-preview // @supportURL https://github.com/WuSanJu/yaohuo-split-preview/issues // @downloadURL https://raw.githubusercontent.com/WuSanJu/yaohuo-split-preview/main/yaohuo-split-preview.user.js // @updateURL https://raw.githubusercontent.com/WuSanJu/yaohuo-split-preview/main/yaohuo-split-preview.user.js // @require https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js // @grant none // @run-at document-idle // ==/UserScript== /*! * yaohuo-split-preview * Repository: https://github.com/WuSanJu/yaohuo-split-preview * Author: 巫山居 * License: MIT */ (function () { 'use strict'; // ========= 可调参数 ========= const STORAGE_RATIO_KEY = 'yaohuo_split_left_ratio_v2_5'; const STORAGE_READ_KEY = 'yaohuo_split_read_threads_v2_5'; const STORAGE_LAST_THREAD_MAP_KEY = 'yaohuo_split_last_thread_map_v1'; const DEFAULT_LEFT_RATIO = 0.40; const MIN_LEFT = 320; const MIN_RIGHT = 360; const DIVIDER_HIT_WIDTH = 10; const VISIBLE_LINE_WIDTH = 2; const HOVER_TRANSITION_MS = 300; const HOLD_TO_DRAG_MS = 500; const ARM_MOVE_TOLERANCE = 8; const AUTO_LOAD_THRESHOLD = 96; const AUTO_LOAD_COOLDOWN_MS = 900; const AUTO_LOAD_FALLBACK_MS = 5000; const LEFT_LOADMORE_POLL_MS = 120; const THREAD_LINK_RE = /\/bbs-(\d+)\.html(?:[?#].*)?$/i; // PNG 导出参数 const PNG_IMAGE_TIMEOUT = 15000; const PNG_MAX_TOTAL_PIXELS = 40000000; const PNG_MAX_SIDE = 16384; const PNG_MIN_SCALE = 0.5; // 右侧帖子页“加载更多评论”自动展开参数 const PREVIEW_LOADMORE_TRIGGER_RE = /加载更多|点击加载更多|展开更多|更多回复|更多评论|继续加载/i; const PREVIEW_LOADMORE_DONE_RE = /没有更多了|没有更多|已无更多|全部加载完毕|已全部加载|已经到底|到底了|加载完毕|全部显示完毕|没有啦/i; const PREVIEW_LOADMORE_LOADING_RE = /加载中|正在加载|请稍候|loading|处理中/i; const PREVIEW_LOADMORE_MAX_CLICKS = 180; const PREVIEW_LOADMORE_CYCLE_TIMEOUT = 12000; const PREVIEW_LOADMORE_TOTAL_TIMEOUT = 180000; const PREVIEW_LOADMORE_POLL_MS = 120; // 左侧列表“按ID恢复定位”参数 const RESTORE_LOCATE_MAX_CLICKS = 260; const RESTORE_LOCATE_TOTAL_TIMEOUT = 180000; const RESTORE_LOCATE_WAIT_TIMEOUT = 9000; const LAST_THREAD_MAP_LIMIT = 40; const isThreadHref = (href) => THREAD_LINK_RE.test(href || ''); function getThreadIdFromHref(href) { const match = (href || '').match(/\/bbs-(\d+)\.html/i); return match ? match[1] : ''; } function getThreadIdFromLink(link) { if (!link) return ''; return getThreadIdFromHref(link.getAttribute('href') || ''); } function buildThreadUrlById(id) { const safeId = String(id || '').trim(); if (!safeId) return ''; return new URL(`/bbs-${safeId}.html`, location.origin).href; } function resolveThreadUrl(link) { return new URL(link.getAttribute('href') || '', location.origin).href; } function getThreadLinkInRow(row) { return Array.from(row.querySelectorAll('a[href]')).find(a => { const href = a.getAttribute('href') || ''; return isThreadHref(href); }) || null; } function normalizeSpace(text) { return String(text || '').replace(/\s+/g, ' ').trim(); } function isElementActuallyVisible(el) { if (!el) return false; const doc = el.ownerDocument || document; const win = doc.defaultView || window; let node = el; while (node && node !== doc.documentElement) { const style = win.getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { return false; } node = node.parentElement; } const rect = typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : { width: 1, height: 1 }; if ((rect.width === 0 && rect.height === 0) && el !== doc.body && el !== doc.documentElement) { return false; } return true; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function nextFrame(win = window) { return new Promise(resolve => win.requestAnimationFrame(() => resolve())); } async function settleFrames(win = window, count = 2) { for (let i = 0; i < count; i++) { await nextFrame(win); } } function sanitizeFileName(name) { return String(name || '妖火帖子') .replace(/[\\/:*?"<>|\u0000-\u001F]+/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 120) || '妖火帖子'; } function formatNowForFile() { const d = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function extractThreadTitleFromDoc(doc) { if (!doc) return ''; try { const postInfo = doc.querySelector('.Postinfo'); if (postInfo) { const clone = postInfo.cloneNode(true); clone.querySelectorAll('.yueduliang, .Postime, .biaotiwenzi').forEach(el => el.remove()); const text = normalizeSpace(clone.textContent || ''); if (text) return text; } } catch (_) {} const rawTitle = normalizeSpace(doc.title || ''); if (!rawTitle) return ''; const firstPart = rawTitle.split(/\s+-\s+/)[0]; return normalizeSpace(firstPart || rawTitle); } function buildPngFileName() { let title = ''; try { title = currentThreadTitle || currentLink?.textContent?.trim() || extractThreadTitleFromDoc(iframe?.contentDocument) || iframe?.contentDocument?.title || '妖火帖子'; } catch (_) { title = currentThreadTitle || currentLink?.textContent?.trim() || '妖火帖子'; } const safeTitle = sanitizeFileName(title); const idPart = currentThreadId ? `_ID${currentThreadId}` : ''; return `${safeTitle}${idPart}_${formatNowForFile()}.png`; } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.rel = 'noopener noreferrer'; a.style.display = 'none'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => { URL.revokeObjectURL(url); }, 15000); } function canvasToBlob(canvas) { return new Promise((resolve, reject) => { try { if (canvas.toBlob) { canvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error('PNG 数据为空')); }, 'image/png'); return; } const dataUrl = canvas.toDataURL('image/png'); fetch(dataUrl) .then(r => r.blob()) .then(resolve) .catch(reject); } catch (err) { reject(err); } }); } let html2canvasReadyPromise = null; function ensureHtml2Canvas() { if (typeof html2canvas === 'function') { return Promise.resolve(html2canvas); } if (html2canvasReadyPromise) { return html2canvasReadyPromise; } const fallbackUrls = [ 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js', 'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js' ]; html2canvasReadyPromise = new Promise((resolve, reject) => { let index = 0; const tryNext = () => { if (typeof html2canvas === 'function') { resolve(html2canvas); return; } if (index >= fallbackUrls.length) { reject(new Error('html2canvas 加载失败')); return; } const script = document.createElement('script'); script.src = fallbackUrls[index++]; script.async = true; script.onload = () => { if (typeof html2canvas === 'function') resolve(html2canvas); else tryNext(); }; script.onerror = () => tryNext(); document.head.appendChild(script); }; tryNext(); }); return html2canvasReadyPromise; } async function waitForIframeAssets(doc, timeout = PNG_IMAGE_TIMEOUT) { if (!doc) return; const tasks = []; const imgs = Array.from(doc.images || []); for (const img of imgs) { if (!img) continue; try { img.loading = 'eager'; } catch (_) {} try { img.decoding = 'sync'; } catch (_) {} const lazyCandidate = img.getAttribute('data-src') || img.getAttribute('data-original') || img.getAttribute('data-url') || img.getAttribute('data-lazy-src'); if (!img.getAttribute('src') && lazyCandidate) { img.setAttribute('src', lazyCandidate); } if (img.complete) { if (typeof img.decode === 'function') { tasks.push(img.decode().catch(() => {})); } continue; } tasks.push(new Promise(resolve => { let done = false; const finish = () => { if (done) return; done = true; cleanup(); resolve(); }; const cleanup = () => { img.removeEventListener('load', finish); img.removeEventListener('error', finish); }; img.addEventListener('load', finish, { once: true }); img.addEventListener('error', finish, { once: true }); setTimeout(finish, timeout); })); } const fontTask = doc.fonts?.ready ? doc.fonts.ready.catch(() => {}) : Promise.resolve(); await Promise.race([ Promise.allSettled([fontTask, ...tasks]), delay(timeout) ]); } // ========= 当前页面判定 ========= const rows = Array.from(document.querySelectorAll('.listdata')); if (!rows.length) return; const firstLink = rows.map(getThreadLinkInRow).find(Boolean); if (!firstLink) return; // ========= 列表上下文(按当前列表URL维度保存最后浏览帖子) ========= function getCanonicalListContextKey() { const url = new URL(location.href); const params = Array.from(url.searchParams.entries()) .filter(([key]) => !/^page$/i.test(key) && !/^gettotal$/i.test(key)) .sort((a, b) => { const keyCmp = String(a[0]).localeCompare(String(b[0])); if (keyCmp !== 0) return keyCmp; return String(a[1]).localeCompare(String(b[1])); }); const qs = new URLSearchParams(); for (const [key, value] of params) { qs.append(key, value); } const query = qs.toString(); return `${url.pathname}${query ? `?${query}` : ''}`; } function loadLastViewedMap() { try { const raw = localStorage.getItem(STORAGE_LAST_THREAD_MAP_KEY); const data = raw ? JSON.parse(raw) : {}; return data && typeof data === 'object' && !Array.isArray(data) ? data : {}; } catch (_) { return {}; } } function pruneLastViewedMap(map) { const entries = Object.entries(map || {}).filter(([, value]) => { return value && typeof value === 'object' && value.id; }); if (entries.length <= LAST_THREAD_MAP_LIMIT) { return map; } entries.sort((a, b) => (Number(b[1].ts) || 0) - (Number(a[1].ts) || 0)); const kept = {}; for (const [key, value] of entries.slice(0, LAST_THREAD_MAP_LIMIT)) { kept[key] = value; } return kept; } function saveLastViewedMap(map) { localStorage.setItem(STORAGE_LAST_THREAD_MAP_KEY, JSON.stringify(pruneLastViewedMap(map))); } function loadLastViewedThreadState() { const map = loadLastViewedMap(); const key = getCanonicalListContextKey(); const value = map[key]; if (!value || typeof value !== 'object' || !value.id) return null; return { id: String(value.id), url: normalizeSpace(value.url || buildThreadUrlById(value.id)), title: normalizeSpace(value.title || ''), ts: Number(value.ts) || 0 }; } function saveLastViewedThreadState(state) { if (!state || !state.id) return; const id = String(state.id || '').trim(); if (!id) return; const key = getCanonicalListContextKey(); const map = loadLastViewedMap(); map[key] = { id, url: normalizeSpace(state.url || buildThreadUrlById(id)), title: normalizeSpace(state.title || ''), ts: Date.now() }; saveLastViewedMap(map); } // ========= 已读记录 ========= function loadReadSet() { try { const raw = localStorage.getItem(STORAGE_READ_KEY); const arr = raw ? JSON.parse(raw) : []; if (!Array.isArray(arr)) return new Set(); return new Set(arr.map(String)); } catch (_) { return new Set(); } } let readSet = loadReadSet(); function saveReadSet() { localStorage.setItem(STORAGE_READ_KEY, JSON.stringify([...readSet])); } // ========= 样式 ========= const style = document.createElement('style'); style.textContent = ` html.yh-split-mode, body.yh-split-mode { height: 100% !important; overflow: hidden !important; } body.yh-split-mode { margin: 0 !important; background: #fff !important; } #yh-split-root { position: fixed; inset: 0; display: flex; background: #fff; z-index: 2147483640; overflow: hidden; } #yh-split-root.is-initializing #yh-left-pane { transition: none !important; } #yh-left-pane, #yh-right-pane { min-width: 0; height: 100%; position: relative; z-index: 1; } #yh-left-pane { flex: 0 0 40%; overflow: hidden; background: #fff; transition: flex-basis 180ms cubic-bezier(.2,.8,.2,1); will-change: flex-basis; } #yh-left-scroll { height: 100%; overflow: auto; -webkit-overflow-scrolling: touch; box-sizing: border-box; background: #fff; scroll-behavior: smooth; } #yh-right-pane { flex: 1 1 auto; overflow: hidden; background: #f8fafc; display: flex; flex-direction: column; min-height: 0; } #yh-right-toolbar { position: sticky; top: 0; z-index: 6; display: flex; align-items: center; gap: 10px; min-height: 42px; padding: 8px 10px; background: linear-gradient(180deg, rgba(255,255,255,.98) 0%, rgba(248,250,252,.96) 100%); border-bottom: 1px solid rgba(203, 213, 225, .95); backdrop-filter: blur(10px); box-shadow: 0 1px 0 rgba(255,255,255,.85), 0 6px 18px rgba(15, 23, 42, .05); flex: 0 0 auto; } #yh-current-title { position: relative; min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; line-height: 1.2; font-weight: 600; color: #334155; letter-spacing: .1px; text-align: left; user-select: text; -webkit-user-select: text; padding-left: 18px; } #yh-current-title::before { content: ''; position: absolute; left: 2px; top: 50%; width: 8px; height: 8px; border-radius: 50%; transform: translateY(-50%); background: linear-gradient(180deg, #d9fbe4 0%, #9ee6b1 100%); box-shadow: 0 0 0 3px rgba(158, 230, 177, .18); } #yh-toolbar-group { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex: 0 0 auto; margin-left: auto; } .yh-toolbar-btn { appearance: none; border: 1px solid #d8e1ea; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); color: #334155; height: 28px; line-height: 26px; padding: 0 10px; border-radius: 10px; font-size: 12.5px; font-weight: 500; letter-spacing: .1px; cursor: pointer; white-space: nowrap; transition: all .16s ease; box-shadow: 0 1px 2px rgba(15, 23, 42, .03), inset 0 1px 0 rgba(255,255,255,.75); } .yh-toolbar-btn:hover:not(:disabled) { color: #1d4ed8; border-color: #93c5fd; background: linear-gradient(180deg, #ffffff 0%, #eff6ff 100%); box-shadow: 0 4px 12px rgba(59, 130, 246, .12), inset 0 1px 0 rgba(255,255,255,.82); } .yh-toolbar-btn:active:not(:disabled) { transform: translateY(1px) scale(.985); box-shadow: 0 2px 6px rgba(59, 130, 246, .10), inset 0 1px 0 rgba(255,255,255,.72); } .yh-toolbar-btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; } #yh-frame-wrap { flex: 1 1 auto; min-height: 0; overflow: hidden; background: #fff; } #yh-preview-frame { width: 100%; height: 100%; border: 0; background: #fff; display: block; } #yh-divider { flex: 0 0 ${DIVIDER_HIT_WIDTH}px; width: ${DIVIDER_HIT_WIDTH}px; position: relative; z-index: 10; cursor: col-resize; touch-action: none; background: transparent; user-select: none; -webkit-user-select: none; } #yh-divider .line, #yh-divider .drag-glow { position: absolute; top: 0; bottom: 0; left: 50%; transform: translateX(-50%); pointer-events: none; border-radius: 999px; } #yh-divider .line { width: ${VISIBLE_LINE_WIDTH}px; } #yh-divider .line-base { opacity: 1; background: linear-gradient( to bottom, #eef1f4 0%, #dfe4ea 18%, #cfd6df 50%, #dfe4ea 82%, #eef1f4 100% ); } #yh-divider .line-hover { opacity: 0; background: linear-gradient( to bottom, #eef8ff 0%, #d6eeff 18%, #9fd1ff 50%, #d6eeff 82%, #eef8ff 100% ); transition: opacity ${HOVER_TRANSITION_MS}ms cubic-bezier(.22,.61,.36,1); } #yh-divider .line-active { opacity: 0; background: linear-gradient( to bottom, #93c5fd 0%, #60a5fa 18%, #2563eb 50%, #1d4ed8 72%, #93c5fd 100% ); transition: opacity ${HOLD_TO_DRAG_MS}ms cubic-bezier(.2,.8,.2,1); } #yh-divider .drag-glow { width: ${VISIBLE_LINE_WIDTH}px; opacity: 0; box-shadow: -18px 0 26px 10px rgba(96, 165, 250, 0.18), 18px 0 26px 10px rgba(96, 165, 250, 0.18); transition: opacity 160ms ease; } #yh-divider.is-hovering .line-hover { opacity: 1; } #yh-divider.is-arming .line-active, #yh-divider.is-armed .line-active, #yh-divider.is-dragging .line-active { opacity: 1; } #yh-divider.is-arming .drag-glow, #yh-divider.is-armed .drag-glow { opacity: .78; } #yh-divider.is-dragging .drag-glow { opacity: 1; } #yh-split-root.is-dragging #yh-left-pane, #yh-split-root.is-dragging #yh-right-pane { pointer-events: none !important; } #yh-split-root.is-dragging #yh-left-pane { transition: none !important; } #yh-drag-mask { position: absolute; inset: 0; z-index: 6; display: none; background: transparent; cursor: col-resize; } #yh-split-root.is-dragging #yh-drag-mask { display: block; } #yh-ratio-badge { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.96); z-index: 11; opacity: 0; pointer-events: none; white-space: nowrap; padding: 8px 12px; border-radius: 12px; font-size: 13px; line-height: 1; font-weight: 600; letter-spacing: .2px; color: #1d4ed8; background: linear-gradient(135deg, rgba(255,255,255,0.96), rgba(239,246,255,0.95)); border: 1px solid rgba(96, 165, 250, 0.42); box-shadow: 0 10px 28px rgba(59, 130, 246, 0.18), 0 2px 8px rgba(59, 130, 246, 0.10), inset 0 1px 0 rgba(255,255,255,0.8); backdrop-filter: blur(8px); transition: opacity 120ms ease, transform 120ms ease, left 40ms linear; } #yh-split-root.is-dragging #yh-ratio-badge { opacity: 1; transform: translate(-50%, -50%) scale(1); } html.yh-split-dragging, html.yh-split-dragging body, html.yh-split-dragging * { user-select: none !important; -webkit-user-select: none !important; cursor: col-resize !important; } #yh-left-scroll .listdata { cursor: pointer; position: relative; margin: 4px 6px; padding-left: 10px !important; padding-right: 8px !important; border-radius: 12px; transition: background-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; } #yh-left-scroll .listdata a.yh-thread-link { transition: color 120ms ease, opacity 120ms ease; } #yh-left-scroll .listdata:not(.yh-preview-active) a.yh-thread-link.yh-thread-read, #yh-left-scroll .listdata:not(.yh-preview-active) a.yh-thread-link.yh-thread-read:visited { color: #d39a9a !important; } #yh-left-scroll .listdata:not(.yh-preview-active) a.yh-thread-link.yh-thread-read:hover { color: #c98585 !important; } #yh-left-scroll .listdata.yh-preview-active { background: linear-gradient(90deg, rgba(240, 253, 244, 0.96) 0%, rgba(220, 252, 231, 0.86) 100%) !important; box-shadow: inset 3px 0 0 #22c55e, 0 0 0 1px rgba(74, 222, 128, 0.22), 0 10px 24px rgba(34, 197, 94, 0.14); } #yh-left-scroll .listdata.yh-preview-active:hover { background: linear-gradient(90deg, rgba(236, 253, 245, 0.98) 0%, rgba(209, 250, 229, 0.90) 100%) !important; box-shadow: inset 3px 0 0 #16a34a, 0 0 0 1px rgba(74, 222, 128, 0.28), 0 12px 28px rgba(34, 197, 94, 0.16); } #yh-left-scroll .listdata.yh-preview-active a.yh-thread-link, #yh-left-scroll .listdata.yh-preview-active a.yh-thread-link:visited, #yh-left-scroll .listdata.yh-preview-active a.yh-thread-link:hover { color: #16a34a !important; font-weight: 600; } #yh-toast { position: absolute; right: 14px; bottom: 14px; z-index: 20; opacity: 0; transform: translateY(6px); pointer-events: none; padding: 8px 12px; border-radius: 10px; font-size: 12px; color: #fff; background: rgba(15, 23, 42, .88); box-shadow: 0 10px 24px rgba(0,0,0,.18); transition: opacity .18s ease, transform .18s ease; max-width: min(460px, 78vw); line-height: 1.45; white-space: normal; word-break: break-word; } #yh-toast.show { opacity: 1; transform: translateY(0); } @media (max-width: 980px) { #yh-right-toolbar { padding: 6px 8px; gap: 8px; min-height: 38px; } #yh-current-title { font-size: 13px; padding-left: 16px; } #yh-current-title::before { width: 7px; height: 7px; } .yh-toolbar-btn { height: 26px; line-height: 24px; padding: 0 8px; border-radius: 9px; font-size: 12px; } } `; document.head.appendChild(style); // ========= 开启双栏 ========= document.documentElement.classList.add('yh-split-mode'); document.body.classList.add('yh-split-mode'); const originalNodes = Array.from(document.body.childNodes); const root = document.createElement('div'); root.id = 'yh-split-root'; root.className = 'is-initializing'; root.innerHTML = `
正在加载帖子...
`; document.body.appendChild(root); const leftPane = root.querySelector('#yh-left-pane'); const leftScroll = root.querySelector('#yh-left-scroll'); const divider = root.querySelector('#yh-divider'); const iframe = root.querySelector('#yh-preview-frame'); const ratioBadge = root.querySelector('#yh-ratio-badge'); const toast = root.querySelector('#yh-toast'); const btnPrev = root.querySelector('[data-action="prev"]'); const btnNext = root.querySelector('[data-action="next"]'); const btnNewTab = root.querySelector('[data-action="newtab"]'); const btnSaveImg = root.querySelector('[data-action="saveimg"]'); const btnClear = root.querySelector('[data-action="clear"]'); const toolbarButtons = Array.from(root.querySelectorAll('.yh-toolbar-btn')); const currentTitleEl = root.querySelector('#yh-current-title'); for (const node of originalNodes) { if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SCRIPT') continue; leftScroll.appendChild(node); } // ========= 状态 ========= let currentLink = null; let currentThreadId = ''; let currentThreadUrl = ''; let currentThreadTitle = ''; let toastTimer = null; let exportPngBusy = false; let restoreLocateSeq = 0; let restoreLocateBusy = false; // ========= 工具方法 ========= function showToast(message, duration = 1600) { clearTimeout(toastTimer); toast.textContent = message; toast.classList.add('show'); if (duration > 0) { toastTimer = setTimeout(() => { toast.classList.remove('show'); }, duration); } else { toastTimer = null; } } function hideToast() { clearTimeout(toastTimer); toastTimer = null; toast.classList.remove('show'); } function setExportStage(buttonLabel, toastMessage, duration = 0) { btnSaveImg.textContent = buttonLabel; if (toastMessage) { showToast(toastMessage, duration); } } function setToolbarBusy(busy) { if (busy) { toolbarButtons.forEach(btn => { btn.disabled = true; }); return; } updateToolbarState(); } function getListThreadLinks() { return Array.from(leftScroll.querySelectorAll('.listdata')) .map(getThreadLinkInRow) .filter(Boolean); } function findLinkByThreadId(id) { const targetId = String(id || ''); return getListThreadLinks().find(link => getThreadIdFromLink(link) === targetId) || null; } function applyReadMarks() { for (const link of getListThreadLinks()) { link.classList.add('yh-thread-link'); const id = getThreadIdFromLink(link); if (id && readSet.has(id)) { link.classList.add('yh-thread-read'); } else { link.classList.remove('yh-thread-read'); } } } function markLinkAsRead(link, { save = true } = {}) { if (!link) return; link.classList.add('yh-thread-link'); const id = getThreadIdFromLink(link); if (!id) return; if (!readSet.has(id)) { readSet.add(id); if (save) saveReadSet(); } link.classList.add('yh-thread-read'); } function getCurrentListContext() { const links = getListThreadLinks(); let index = -1; if (currentLink) { index = links.indexOf(currentLink); } if (index < 0 && currentThreadId) { const found = links.find(link => getThreadIdFromLink(link) === currentThreadId); if (found) { currentLink = found; index = links.indexOf(found); } } return { links, index }; } function updateToolbarState() { const { links, index } = getCurrentListContext(); btnPrev.disabled = index <= 0; btnNext.disabled = index < 0 || index >= links.length - 1; btnNewTab.disabled = !(currentThreadUrl || currentThreadId); btnSaveImg.disabled = !(currentThreadUrl || currentThreadId); btnClear.disabled = false; const title = (currentLink && normalizeSpace(currentLink.textContent)) || currentThreadTitle || (currentThreadId ? `帖子 ID ${currentThreadId}` : '未选择帖子'); currentTitleEl.textContent = title; currentTitleEl.title = title; if (exportPngBusy) { toolbarButtons.forEach(btn => { btn.disabled = true; }); } } function centerRowInView(row, behavior = 'smooth') { if (!row) return; const containerRect = leftScroll.getBoundingClientRect(); const rowRect = row.getBoundingClientRect(); const targetTop = leftScroll.scrollTop + (rowRect.top - containerRect.top) - (containerRect.height / 2 - rowRect.height / 2); const maxTop = Math.max(0, leftScroll.scrollHeight - leftScroll.clientHeight); const safeTop = Math.max(0, Math.min(maxTop, targetTop)); leftScroll.scrollTo({ top: safeTop, behavior }); } function clearActive() { leftScroll.querySelectorAll('.yh-preview-active').forEach(el => { el.classList.remove('yh-preview-active'); }); } function highlightActiveLink(link, { scrollBehavior = false } = {}) { clearActive(); if (!link) return; const row = link.closest('.listdata'); if (row) { row.classList.add('yh-preview-active'); if (scrollBehavior) { centerRowInView(row, scrollBehavior === true ? 'smooth' : scrollBehavior); } } } function setVirtualActiveThread({ id = '', url = '', title = '', clearHighlight = true, saveLast = true } = {}) { currentLink = null; currentThreadId = String(id || '').trim(); currentThreadUrl = normalizeSpace(url || buildThreadUrlById(currentThreadId)); currentThreadTitle = normalizeSpace(title || currentThreadTitle || ''); if (clearHighlight) { clearActive(); } if (saveLast && currentThreadId) { saveLastViewedThreadState({ id: currentThreadId, url: currentThreadUrl, title: currentThreadTitle }); } updateToolbarState(); } function setActive(link, { scrollBehavior = 'smooth', saveLast = true, urlOverride = '', titleOverride = '' } = {}) { if (!link) return; currentLink = link; currentThreadId = getThreadIdFromLink(link); currentThreadUrl = normalizeSpace(urlOverride || resolveThreadUrl(link)); currentThreadTitle = normalizeSpace(titleOverride || link.textContent || currentThreadTitle || ''); highlightActiveLink(link, { scrollBehavior }); updateToolbarState(); if (saveLast && currentThreadId) { saveLastViewedThreadState({ id: currentThreadId, url: currentThreadUrl, title: currentThreadTitle }); } } function refreshCurrentHighlight({ scrollBehavior = false } = {}) { if (!currentThreadId) { clearActive(); currentLink = null; updateToolbarState(); return null; } const link = findLinkByThreadId(currentThreadId); currentLink = link || null; if (!link) { clearActive(); updateToolbarState(); return null; } if (!currentThreadTitle) { currentThreadTitle = normalizeSpace(link.textContent || ''); } highlightActiveLink(link, { scrollBehavior }); updateToolbarState(); return link; } function cancelPendingRestoreLocate() { restoreLocateSeq += 1; restoreLocateBusy = false; } function loadPreview(link, { scrollBehavior = 'smooth', markRead = true, forceReload = false, cancelPendingLocate = true } = {}) { if (!link) return; const href = link.getAttribute('href') || ''; if (!isThreadHref(href)) return; if (cancelPendingLocate) { cancelPendingRestoreLocate(); } const url = resolveThreadUrl(link); if (markRead) { markLinkAsRead(link, { save: true }); } setActive(link, { scrollBehavior, saveLast: true, urlOverride: url, titleOverride: normalizeSpace(link.textContent || '') }); if (forceReload || iframe.dataset.currentUrl !== url) { iframe.dataset.currentUrl = url; iframe.src = url; } } function reloadCurrentPreview() { const targetUrl = currentThreadUrl || buildThreadUrlById(currentThreadId); if (!targetUrl) return; try { if (iframe.contentWindow && iframe.contentWindow.location) { iframe.contentWindow.location.reload(); return; } } catch (_) {} iframe.dataset.currentUrl = ''; iframe.src = targetUrl; } function navigateRelative(delta) { const { links, index } = getCurrentListContext(); if (!links.length) return; let nextIndex; if (index < 0) { nextIndex = delta > 0 ? 0 : links.length - 1; } else { nextIndex = index + delta; } if (nextIndex < 0) { showToast('已经是第一帖'); updateToolbarState(); return; } if (nextIndex >= links.length) { showToast('已经是最后一帖'); updateToolbarState(); return; } loadPreview(links[nextIndex], { scrollBehavior: 'smooth', markRead: true, forceReload: false, cancelPendingLocate: true }); } function openCurrentInNewTab() { const url = currentThreadUrl || buildThreadUrlById(currentThreadId); if (!url) return; window.open(url, '_blank', 'noopener,noreferrer'); } function clearTraceAndRefresh() { cancelPendingRestoreLocate(); const pageLinks = getListThreadLinks(); const pageIds = new Set( pageLinks.map(link => getThreadIdFromLink(link)).filter(Boolean) ); const keepId = currentThreadId ? String(currentThreadId) : ''; let removedCount = 0; for (const id of pageIds) { if (id !== keepId && readSet.has(id)) { readSet.delete(id); removedCount++; } } if (keepId) { readSet.add(keepId); } saveReadSet(); applyReadMarks(); if (currentLink) { currentLink.classList.add('yh-thread-link'); currentLink.classList.add('yh-thread-read'); } refreshCurrentHighlight({ scrollBehavior: false }); reloadCurrentPreview(); showToast(removedCount ? `已清除本页 ${removedCount} 条痕迹` : '本页没有可清除痕迹'); } function jumpToTopFirstThread() { cancelPendingRestoreLocate(); const links = getListThreadLinks(); const topLink = links[0] || firstLink; if (!topLink) { showToast('未找到顶部第一个帖子'); return; } loadPreview(topLink, { scrollBehavior: false, markRead: true, forceReload: false, cancelPendingLocate: true }); try { leftScroll.scrollTo({ top: 0, behavior: 'smooth' }); } catch (_) { leftScroll.scrollTop = 0; } showToast('已回到顶部并打开第一个帖子', 1500); } // ========= iframe 正文净化 ========= function simplifyIframeDocument(doc) { if (!doc || !doc.body) return; const body = doc.body; const styleId = 'yh-preview-clean-style'; if (!doc.getElementById(styleId)) { const cleanStyle = doc.createElement('style'); cleanStyle.id = styleId; cleanStyle.textContent = ` html, body { background: #fff !important; overflow-x: hidden !important; } body { margin: 0 !important; padding: 0 !important; } [data-yh-preview-hidden="1"] { display: none !important; } .content, .tipmini, .louzhuxinxi, .viewContent { margin-left: 0 !important; margin-right: 0 !important; } .content { padding-top: 0 !important; } img, iframe { max-width: 100% !important; } `; (doc.head || doc.documentElement).appendChild(cleanStyle); } Array.from(body.children).forEach(el => { el.removeAttribute('data-yh-preview-hidden'); }); const children = Array.from(body.children); const mainStart = children.find(el => el.classList && el.classList.contains('content')); if (mainStart) { let node = body.firstElementChild; while (node && node !== mainStart) { node.setAttribute('data-yh-preview-hidden', '1'); node = node.nextElementSibling; } } const bottomStart = children.find(el => { if (!el.classList || !el.classList.contains('title')) return false; const text = (el.textContent || '').replace(/\s+/g, ''); return text.includes('发表主题') && text.includes('最新'); }); if (bottomStart) { let node = bottomStart; while (node) { node.setAttribute('data-yh-preview-hidden', '1'); node = node.nextElementSibling; } } } function getElementActionText(el) { if (!el) return ''; const tag = (el.tagName || '').toLowerCase(); if (tag === 'input') { return normalizeSpace(el.value || el.getAttribute('value') || ''); } return normalizeSpace(el.innerText || el.textContent || ''); } function buildPreviewLoadMoreState(control, text = '') { const normalizedText = normalizeSpace(text || getElementActionText(control)); const finished = PREVIEW_LOADMORE_DONE_RE.test(normalizedText); const loading = PREVIEW_LOADMORE_LOADING_RE.test(normalizedText); const actionable = PREVIEW_LOADMORE_TRIGGER_RE.test(normalizedText) && !finished && !loading; return { control: control || null, text: normalizedText, finished, loading, actionable }; } function readPreviewLoadMoreState(doc) { if (!doc) return buildPreviewLoadMoreState(null, ''); const seen = new Set(); const candidates = []; const pushCandidate = (el, textOverride = '') => { if (!el || seen.has(el)) return; seen.add(el); if (!isElementActuallyVisible(el)) return; const text = normalizeSpace(textOverride || getElementActionText(el)); if (!text) return; const state = buildPreviewLoadMoreState(el, text); if (!state.actionable && !state.loading && !state.finished) return; candidates.push(state); }; const preferredSelectors = [ '#KL_loadmore', '.reply-load-more', '.load-more', '.loadmore', '[data-action="loadmore"]', '.more a', '.more button', '.more input[type="button"]', '.more input[type="submit"]' ]; for (const selector of preferredSelectors) { doc.querySelectorAll(selector).forEach(el => pushCandidate(el)); } const tip = doc.querySelector('#KL_show_tip'); if (tip) { pushCandidate(tip.closest('a,button,[role="button"]') || tip, getElementActionText(tip)); } doc.querySelectorAll('a, button, input[type="button"], input[type="submit"], [role="button"]').forEach(el => { pushCandidate(el); }); return ( candidates.find(item => item.actionable) || candidates.find(item => item.loading) || candidates.find(item => item.finished) || buildPreviewLoadMoreState(null, '') ); } function getPreviewLoadMetrics(doc) { if (!doc) return { replyCount: 0, replyHeight: 0 }; const replyCount = doc.querySelectorAll('.list-reply, .reline[data-floor], .reply-item, .replyline').length; const container = doc.querySelector('.recontent') || doc.querySelector('.viewContent') || doc.querySelector('#replyList') || doc.querySelector('.reply-list') || doc.body; const replyHeight = Math.max( Math.ceil(container?.scrollHeight || 0), Math.ceil(doc.body?.scrollHeight || 0), Math.ceil(doc.documentElement?.scrollHeight || 0) ); return { replyCount, replyHeight }; } function hideFinishedPreviewLoadMore(doc) { const state = readPreviewLoadMoreState(doc); if (!state.control || !state.finished) return; const box = state.control.closest('.more, .load-more, .loadmore, .reply-load-more') || state.control; box.setAttribute('data-yh-preview-hidden', '1'); } function silentClickPreviewControl(control, win) { if (!control) return; try { control.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {} try { control.click(); return; } catch (_) {} try { control.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: win || window })); } catch (_) {} } async function waitForPreviewLoadMoreProgress(doc, prevMetric, prevText, timeout = PREVIEW_LOADMORE_CYCLE_TIMEOUT) { const start = Date.now(); let sawLoading = false; while (Date.now() - start < timeout) { const state = readPreviewLoadMoreState(doc); const metric = getPreviewLoadMetrics(doc); if (state.loading) { sawLoading = true; } if (state.finished) { return { timeout: false, state, metric, reason: 'finished' }; } if (metric.replyCount > prevMetric.replyCount) { return { timeout: false, state, metric, reason: 'reply-count-grown' }; } if (metric.replyHeight > prevMetric.replyHeight + 24) { return { timeout: false, state, metric, reason: 'reply-height-grown' }; } if (sawLoading && !state.loading) { return { timeout: false, state, metric, reason: 'loading-finished' }; } if (!sawLoading && state.text && prevText && state.text !== prevText && !state.loading) { return { timeout: false, state, metric, reason: 'text-changed' }; } if (!state.control && prevText) { return { timeout: false, state, metric, reason: 'control-missing' }; } await delay(PREVIEW_LOADMORE_POLL_MS); } return { timeout: true, state: readPreviewLoadMoreState(doc), metric: getPreviewLoadMetrics(doc), reason: 'timeout' }; } async function autoExpandPreviewReplies(doc, win) { if (!doc || !win) { return { status: 'error', clicks: 0, metric: { replyCount: 0, replyHeight: 0 }, text: '' }; } const startedAt = Date.now(); let clicks = 0; let everFound = false; while ( Date.now() - startedAt < PREVIEW_LOADMORE_TOTAL_TIMEOUT && clicks < PREVIEW_LOADMORE_MAX_CLICKS ) { simplifyIframeDocument(doc); const state = readPreviewLoadMoreState(doc); const metric = getPreviewLoadMetrics(doc); if (!state.control) { return { status: everFound || clicks > 0 ? 'done' : 'not-found', clicks, metric, text: '' }; } everFound = true; if (state.finished) { hideFinishedPreviewLoadMore(doc); return { status: 'done', clicks, metric, text: state.text }; } if (state.loading) { showToast(`① 正在等待评论加载完成… 当前约 ${metric.replyCount} 条`, 0); const progress = await waitForPreviewLoadMoreProgress(doc, metric, state.text, PREVIEW_LOADMORE_CYCLE_TIMEOUT); simplifyIframeDocument(doc); if (progress.timeout) { return { status: 'timeout', clicks, metric: progress.metric, text: progress.state.text }; } await settleFrames(win, 1); continue; } if (!state.actionable) { return { status: clicks > 0 ? 'done' : 'not-found', clicks, metric, text: state.text }; } clicks += 1; showToast(`① 正在静默展开评论(第 ${clicks} 次,当前约 ${metric.replyCount} 条)…`, 0); silentClickPreviewControl(state.control, win); const progress = await waitForPreviewLoadMoreProgress(doc, metric, state.text, PREVIEW_LOADMORE_CYCLE_TIMEOUT); simplifyIframeDocument(doc); if (progress.timeout) { return { status: 'timeout', clicks, metric: progress.metric, text: progress.state.text }; } await settleFrames(win, 1); } return { status: clicks >= PREVIEW_LOADMORE_MAX_CLICKS ? 'limit' : 'timeout', clicks, metric: getPreviewLoadMetrics(doc), text: readPreviewLoadMoreState(doc).text }; } function buildExpandSummary(result) { if (!result) { return '② 正在预载图片和字体…'; } const count = result?.metric?.replyCount; const countText = Number.isFinite(count) && count > 0 ? `,当前约 ${count} 条回复` : ''; switch (result.status) { case 'done': if (result.clicks > 0) { return `① 评论已全部展开(自动点击 ${result.clicks} 次${countText}) · ② 正在预载图片和字体…`; } if (PREVIEW_LOADMORE_DONE_RE.test(result.text || '')) { return `① 评论本来已全部展开${countText} · ② 正在预载图片和字体…`; } return '① 未发现“加载更多”入口 · ② 正在预载图片和字体…'; case 'not-found': return '① 未发现“加载更多”入口 · ② 正在预载图片和字体…'; case 'timeout': return `① 评论展开等待超时,按当前已加载内容继续${countText} · ② 正在预载图片和字体…`; case 'limit': return `① 评论很多,已达到自动展开上限,按当前内容继续${countText} · ② 正在预载图片和字体…`; case 'error': return `① 自动展开评论失败,按当前内容继续${countText} · ② 正在预载图片和字体…`; default: return '② 正在预载图片和字体…'; } } async function exportCurrentPreviewAsPng() { if (exportPngBusy) return; const targetUrl = currentThreadUrl || buildThreadUrlById(currentThreadId); if (!targetUrl) { showToast('当前没有可导出的帖子'); return; } let doc = null; let win = null; let prevScrollX = 0; let prevScrollY = 0; const oldBtnText = btnSaveImg.textContent; let canvas = null; let expandResult = null; exportPngBusy = true; setToolbarBusy(true); setExportStage('展开评论...', '① 正在检查并静默展开评论…', 0); try { await ensureHtml2Canvas(); doc = iframe.contentDocument; win = iframe.contentWindow; if (!doc || !win || !doc.body || !doc.documentElement) { throw new Error('预览页面尚未加载完成'); } simplifyIframeDocument(doc); prevScrollX = win.scrollX || 0; prevScrollY = win.scrollY || 0; try { expandResult = await autoExpandPreviewReplies(doc, win); } catch (err) { console.warn('[yaohuo split preview] 自动展开评论失败:', err); expandResult = { status: 'error', clicks: 0, metric: getPreviewLoadMetrics(doc), text: '' }; } hideFinishedPreviewLoadMore(doc); simplifyIframeDocument(doc); try { win.scrollTo(0, 0); } catch (_) {} await settleFrames(win, 2); setExportStage('预载资源...', buildExpandSummary(expandResult), 0); await waitForIframeAssets(doc, PNG_IMAGE_TIMEOUT); await settleFrames(win, 2); const html = doc.documentElement; const body = doc.body; const fullWidth = Math.max( Math.ceil(iframe.clientWidth || 0), Math.ceil(win.innerWidth || 0), Math.ceil(html.clientWidth || 0), Math.ceil(html.scrollWidth || 0), Math.ceil(body.scrollWidth || 0), 1 ); const fullHeight = Math.max( Math.ceil(body.scrollHeight || 0), Math.ceil(html.scrollHeight || 0), Math.ceil(body.offsetHeight || 0), Math.ceil(html.offsetHeight || 0), Math.ceil(html.clientHeight || 0), 1 ); if (fullWidth < 1 || fullHeight < 1) { throw new Error('无法获取页面尺寸'); } const baseScale = Math.max( 2, Number(win.devicePixelRatio) || Number(window.devicePixelRatio) || 1 ); const scaleByPixels = Math.sqrt(PNG_MAX_TOTAL_PIXELS / Math.max(1, fullWidth * fullHeight)); const scaleBySide = Math.min( PNG_MAX_SIDE / Math.max(1, fullWidth), PNG_MAX_SIDE / Math.max(1, fullHeight) ); let scale = Math.min(baseScale, scaleByPixels, scaleBySide); if (!Number.isFinite(scale) || scale <= 0) { scale = 1; } if (scale < PNG_MIN_SCALE) { scale = PNG_MIN_SCALE; } setExportStage('渲染PNG...', '③ 正在生成 PNG,长帖会稍慢,请稍候…', 0); canvas = await html2canvas(body, { backgroundColor: '#ffffff', useCORS: true, allowTaint: false, logging: false, imageTimeout: PNG_IMAGE_TIMEOUT, removeContainer: true, foreignObjectRendering: false, scale, width: fullWidth, height: fullHeight, x: 0, y: 0, scrollX: 0, scrollY: 0, windowWidth: fullWidth, windowHeight: fullHeight, onclone: (clonedDoc) => { const styleEl = clonedDoc.createElement('style'); styleEl.textContent = ` html, body { margin: 0 !important; padding: 0 !important; background: #fff !important; overflow: visible !important; } [data-yh-preview-hidden="1"] { display: none !important; } img, iframe { max-width: 100% !important; } `; (clonedDoc.head || clonedDoc.documentElement).appendChild(styleEl); } }); const blob = await canvasToBlob(canvas); const filename = buildPngFileName(); downloadBlob(blob, filename); showToast(`✅ 已导出 PNG:${filename}`, 3200); } catch (err) { console.error('[yaohuo split preview] 导出 PNG 失败:', err); const msg = String(err?.message || err || ''); if (/html2canvas/i.test(msg)) { showToast('导出失败:截图引擎加载失败,请稍后重试', 3000); } else if (/taint|cross-origin|security|Tainted canvases/i.test(msg)) { showToast('导出失败:页面里有防盗链/跨域图片,浏览器不允许写入 PNG', 3200); } else if (/预览页面尚未加载完成/i.test(msg)) { showToast('导出失败:右侧预览还没加载完,请稍后再试', 2600); } else { showToast('导出失败:请稍后重试,或换一帖再试', 2600); } } finally { try { if (win) { win.scrollTo(prevScrollX, prevScrollY); } } catch (_) {} if (canvas) { try { canvas.width = 0; canvas.height = 0; } catch (_) {} } exportPngBusy = false; btnSaveImg.textContent = oldBtnText; setToolbarBusy(false); } } // ========= 宽度逻辑 ========= function getUsableWidth() { return Math.max(0, root.clientWidth - DIVIDER_HIT_WIDTH); } function getBounds() { const usable = getUsableWidth(); if (usable <= MIN_LEFT + MIN_RIGHT) { const fallback = Math.round(usable * DEFAULT_LEFT_RATIO); const safe = Math.min(usable, Math.max(0, fallback)); return { minLeft: safe, maxLeft: safe }; } return { minLeft: MIN_LEFT, maxLeft: usable - MIN_RIGHT }; } function clampLeft(px) { const usable = getUsableWidth(); if (usable <= 0) return 0; const { minLeft, maxLeft } = getBounds(); return Math.max(minLeft, Math.min(px, maxLeft)); } function formatPercent(v) { const rounded = Math.round(v); return Math.abs(v - rounded) < 0.05 ? `${rounded}%` : `${v.toFixed(1)}%`; } function updateRatioBadge() { const usable = Math.max(1, getUsableWidth()); const leftWidth = leftPane.getBoundingClientRect().width; const leftPercent = (leftWidth / usable) * 100; const rightPercent = Math.max(0, 100 - leftPercent); ratioBadge.textContent = `左 ${formatPercent(leftPercent)}|右 ${formatPercent(rightPercent)}`; const dividerCenter = leftWidth + DIVIDER_HIT_WIDTH / 2; const half = Math.max(86, ratioBadge.offsetWidth / 2 + 14); const safeX = Math.max(half, Math.min(root.clientWidth - half, dividerCenter)); ratioBadge.style.left = `${safeX}px`; } function saveCurrentRatio() { const usable = getUsableWidth(); if (usable <= 0) return; const leftWidth = leftPane.getBoundingClientRect().width; const ratio = leftWidth / usable; localStorage.setItem(STORAGE_RATIO_KEY, String(ratio.toFixed(4))); } function setLeftPx(px, { save = false } = {}) { const safePx = clampLeft(px); leftPane.style.flex = `0 0 ${safePx}px`; updateRatioBadge(); if (save) saveCurrentRatio(); } function applySavedRatio() { const usable = getUsableWidth(); if (usable <= 0) return; let saved = parseFloat(localStorage.getItem(STORAGE_RATIO_KEY) || String(DEFAULT_LEFT_RATIO)); if (!Number.isFinite(saved)) saved = DEFAULT_LEFT_RATIO; saved = Math.max(0.1, Math.min(0.9, saved)); setLeftPx(usable * saved, { save: false }); } // ========= 左栏“加载更多”共享引擎 ========= let knownThreadCount = 0; let lastLoadMoreText = ''; let autoLoadCheckTimer = 0; let autoLoadCheckRaf = 0; let mutationRaf = 0; let leftLoadMorePromise = null; let leftLoadMoreLastClickAt = 0; let leftLoadMoreLastReason = ''; function getLoadMoreLink() { return leftScroll.querySelector('#KL_loadmore'); } function getLoadMoreText() { const link = getLoadMoreLink(); const tip = leftScroll.querySelector('#KL_show_tip'); return normalizeSpace(`${tip ? tip.textContent : ''} ${link ? link.textContent : ''}`); } function parseLoadMoreProgress(text) { const match = String(text || '').match(/(\d+)\s*\/\s*(\d+)/); if (!match) return null; const current = Number(match[1]); const total = Number(match[2]); if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0) return null; return { current, total }; } function isLoadMoreBusy(text = getLoadMoreText()) { return /加载中|正在|请稍候|loading/i.test(text); } function isLoadMoreFinished(text = getLoadMoreText()) { const progress = parseLoadMoreProgress(text); if (progress && progress.current >= progress.total) return true; return /没有更多|已全部加载|全部加载完成|全部加载|到底了|末页|最后一页|加载完毕|结束/i.test(text); } function canClickLoadMore() { const link = getLoadMoreLink(); if (!link) return false; if (!isElementActuallyVisible(link)) return false; const text = getLoadMoreText(); if (isLoadMoreBusy(text)) return false; if (isLoadMoreFinished(text)) return false; return true; } function isNearBottom(container, threshold = AUTO_LOAD_THRESHOLD) { return container.scrollTop + container.clientHeight >= container.scrollHeight - threshold; } function clearAutoLoadTimers() { if (autoLoadCheckTimer) { clearTimeout(autoLoadCheckTimer); autoLoadCheckTimer = 0; } if (autoLoadCheckRaf) { cancelAnimationFrame(autoLoadCheckRaf); autoLoadCheckRaf = 0; } } function scheduleAutoLoadCheck(delayMs = 0) { if (autoLoadCheckTimer) { clearTimeout(autoLoadCheckTimer); autoLoadCheckTimer = 0; } if (autoLoadCheckRaf) { cancelAnimationFrame(autoLoadCheckRaf); autoLoadCheckRaf = 0; } const run = () => { autoLoadCheckRaf = requestAnimationFrame(() => { autoLoadCheckRaf = 0; maybeAutoLoadMore(); }); }; if (delayMs > 0) { autoLoadCheckTimer = setTimeout(() => { autoLoadCheckTimer = 0; run(); }, delayMs); } else { run(); } } function dispatchElementClick(el) { if (!el) return false; try { el.click(); return true; } catch (_) {} try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); return true; } catch (_) {} return false; } async function waitForLeftListLoadMoreProgress({ prevCount, prevText, timeout = AUTO_LOAD_FALLBACK_MS, taskSeq } = {}) { const start = Date.now(); let sawBusy = isLoadMoreBusy(prevText); while (Date.now() - start < timeout) { if (taskSeq != null && taskSeq !== restoreLocateSeq) { return { cancelled: true, count: getListThreadLinks().length, text: getLoadMoreText() }; } const count = getListThreadLinks().length; const text = getLoadMoreText(); const busy = isLoadMoreBusy(text); if (count > prevCount) { return { changed: true, count, text, reason: 'count-grown' }; } if (isLoadMoreFinished(text)) { return { changed: true, count, text, reason: 'finished' }; } if (busy) { sawBusy = true; } if (sawBusy && !busy) { return { changed: true, count, text, reason: 'busy-settled' }; } if (!busy && text && prevText && text !== prevText) { return { changed: true, count, text, reason: 'text-changed' }; } await delay(LEFT_LOADMORE_POLL_MS); } return { timeout: true, count: getListThreadLinks().length, text: getLoadMoreText() }; } function runLeftListLoadMoreCycle({ reason = 'unknown', timeout = AUTO_LOAD_FALLBACK_MS, ignoreCooldown = false, taskSeq } = {}) { if (leftLoadMorePromise) { return leftLoadMorePromise; } let promise = null; promise = (async () => { if (taskSeq != null && taskSeq !== restoreLocateSeq) { return { cancelled: true, status: 'cancelled' }; } const beforeCount = getListThreadLinks().length; const beforeText = getLoadMoreText(); const link = getLoadMoreLink(); if (!link || !isElementActuallyVisible(link)) { return { status: 'no-control', clicked: false, count: beforeCount, text: beforeText, finished: isLoadMoreFinished(beforeText) }; } if (isLoadMoreFinished(beforeText)) { return { status: 'finished', clicked: false, count: beforeCount, text: beforeText, finished: true }; } if (isLoadMoreBusy(beforeText)) { const progress = await waitForLeftListLoadMoreProgress({ prevCount: beforeCount, prevText: beforeText, timeout, taskSeq }); if (progress.cancelled) { return { cancelled: true, status: 'cancelled' }; } if (progress.timeout) { return { status: 'timeout', clicked: false, count: progress.count, text: progress.text, progressReason: 'timeout' }; } const status = progress.reason === 'count-grown' ? 'loaded' : (isLoadMoreFinished(progress.text) ? 'finished' : 'settled'); return { status, clicked: false, count: progress.count, text: progress.text, progressReason: progress.reason }; } const elapsedSinceLastClick = Date.now() - leftLoadMoreLastClickAt; if (!ignoreCooldown && elapsedSinceLastClick < AUTO_LOAD_COOLDOWN_MS) { const remaining = AUTO_LOAD_COOLDOWN_MS - elapsedSinceLastClick; scheduleAutoLoadCheck(remaining + 40); return { status: 'cooldown', clicked: false, count: beforeCount, text: beforeText, remaining }; } const clicked = dispatchElementClick(link); if (!clicked) { return { status: 'click-failed', clicked: false, count: beforeCount, text: beforeText }; } leftLoadMoreLastClickAt = Date.now(); leftLoadMoreLastReason = reason; const progress = await waitForLeftListLoadMoreProgress({ prevCount: beforeCount, prevText: beforeText, timeout, taskSeq }); if (progress.cancelled) { return { cancelled: true, status: 'cancelled' }; } if (progress.timeout) { return { status: 'timeout', clicked: true, count: progress.count, text: progress.text, progressReason: 'timeout' }; } const status = progress.reason === 'count-grown' ? 'loaded' : (isLoadMoreFinished(progress.text) ? 'finished' : 'settled'); return { status, clicked: true, count: progress.count, text: progress.text, progressReason: progress.reason }; })().finally(() => { if (leftLoadMorePromise === promise) { leftLoadMorePromise = null; } scheduleAutoLoadCheck(90); }); leftLoadMorePromise = promise; return promise; } function maybeAutoLoadMore() { if (restoreLocateBusy) return; if (!isNearBottom(leftScroll, AUTO_LOAD_THRESHOLD)) return; if (!getLoadMoreLink()) return; if (isLoadMoreFinished()) return; runLeftListLoadMoreCycle({ reason: 'scroll-bottom', timeout: AUTO_LOAD_FALLBACK_MS, ignoreCooldown: false }).catch(() => {}); } function handleLeftListMutation() { mutationRaf = 0; const threadCount = getListThreadLinks().length; const loadMoreText = getLoadMoreText(); const countChanged = threadCount !== knownThreadCount; const textChanged = loadMoreText !== lastLoadMoreText; if (countChanged) knownThreadCount = threadCount; if (textChanged) lastLoadMoreText = loadMoreText; applyReadMarks(); refreshCurrentHighlight({ scrollBehavior: false }); if (countChanged || textChanged) { updateToolbarState(); scheduleAutoLoadCheck(90); } } const leftListObserver = new MutationObserver(() => { if (mutationRaf) return; mutationRaf = requestAnimationFrame(handleLeftListMutation); }); leftListObserver.observe(leftScroll, { childList: true, subtree: true, characterData: true }); // ========= 左栏按帖子ID恢复定位(复用共享加载更多引擎) ========= async function locateThreadInLeftListById(threadId, { taskSeq } = {}) { const startedAt = Date.now(); let clicks = 0; while ( Date.now() - startedAt < RESTORE_LOCATE_TOTAL_TIMEOUT && clicks <= RESTORE_LOCATE_MAX_CLICKS ) { if (taskSeq != null && taskSeq !== restoreLocateSeq) { return { cancelled: true, link: null, clicks }; } const found = findLinkByThreadId(threadId); if (found) { return { status: 'found', link: found, clicks }; } const loadMoreLink = getLoadMoreLink(); const loadText = getLoadMoreText(); if (!loadMoreLink || isLoadMoreFinished(loadText)) { return { status: 'exhausted', link: null, clicks, text: loadText }; } if (!isLoadMoreBusy(loadText)) { if (clicks >= RESTORE_LOCATE_MAX_CLICKS) { return { status: 'limit', link: null, clicks, text: loadText }; } clicks += 1; showToast(`正在定位上次浏览帖子(ID ${threadId}),自动加载更多中… 第 ${clicks} 次`, 0); } else { showToast(`正在定位上次浏览帖子(ID ${threadId}),等待列表加载…`, 0); } const cycle = await runLeftListLoadMoreCycle({ reason: `restore-locate:${threadId}`, timeout: RESTORE_LOCATE_WAIT_TIMEOUT, ignoreCooldown: true, taskSeq }); if (taskSeq != null && taskSeq !== restoreLocateSeq) { return { cancelled: true, link: null, clicks }; } const foundAfter = findLinkByThreadId(threadId); if (foundAfter) { return { status: 'found', link: foundAfter, clicks }; } if (cycle.cancelled || cycle.status === 'cancelled') { return { cancelled: true, link: null, clicks }; } if (cycle.status === 'no-control' || cycle.status === 'finished' || cycle.status === 'click-failed') { return { status: 'exhausted', link: null, clicks, text: cycle.text || getLoadMoreText() }; } if (cycle.status === 'cooldown') { await delay(Math.min(500, Math.max(80, Number(cycle.remaining) || 120))); continue; } if (cycle.status === 'timeout') { const currentText = getLoadMoreText(); if (!getLoadMoreLink() || isLoadMoreFinished(currentText)) { return { status: 'exhausted', link: null, clicks, text: currentText }; } continue; } } return { status: 'limit', link: findLinkByThreadId(threadId), clicks, text: getLoadMoreText() }; } async function restoreLastViewedThreadOnOpen() { const last = loadLastViewedThreadState(); if (!last || !last.id) { return false; } const taskSeq = ++restoreLocateSeq; restoreLocateBusy = true; const savedId = String(last.id); const savedUrl = normalizeSpace(last.url || buildThreadUrlById(savedId)); const savedTitle = normalizeSpace(last.title || ''); setVirtualActiveThread({ id: savedId, url: savedUrl, title: savedTitle, clearHighlight: true, saveLast: false }); if (savedUrl) { iframe.dataset.currentUrl = savedUrl; iframe.src = savedUrl; } const immediateLink = findLinkByThreadId(savedId); if (immediateLink) { if (taskSeq !== restoreLocateSeq) return true; markLinkAsRead(immediateLink, { save: true }); setActive(immediateLink, { scrollBehavior: 'auto', saveLast: true, urlOverride: savedUrl, titleOverride: savedTitle || normalizeSpace(immediateLink.textContent || '') }); restoreLocateBusy = false; showToast('已恢复到上次浏览帖子', 1400); return true; } showToast(`正在恢复上次浏览帖子(ID ${savedId})…`, 0); try { const result = await locateThreadInLeftListById(savedId, { taskSeq }); if (taskSeq !== restoreLocateSeq) { return true; } restoreLocateBusy = false; if (result.cancelled) { return true; } if (result.link) { markLinkAsRead(result.link, { save: true }); setActive(result.link, { scrollBehavior: 'auto', saveLast: true, urlOverride: savedUrl, titleOverride: savedTitle || normalizeSpace(result.link.textContent || '') }); if (savedUrl && iframe.dataset.currentUrl !== savedUrl) { iframe.dataset.currentUrl = savedUrl; iframe.src = savedUrl; } if (result.clicks > 0) { showToast(`已恢复上次浏览帖子(自动加载更多 ${result.clicks} 次)`, 1800); } else { showToast('已恢复到上次浏览帖子', 1400); } return true; } updateToolbarState(); showToast(`右侧已恢复帖子,左侧未找到 ID ${savedId}`, 3000); return true; } catch (err) { restoreLocateBusy = false; console.warn('[yaohuo split preview] 恢复上次浏览帖子失败:', err); updateToolbarState(); showToast('恢复上次浏览帖子失败,已保留右侧帖子页面', 2600); return true; } } // ========= 左侧点击逻辑 ========= leftScroll.addEventListener('click', (e) => { const loadMoreBtn = e.target.closest('#KL_loadmore'); if (loadMoreBtn) { return; } const a = e.target.closest('a[href]'); if (a) { const href = a.getAttribute('href') || ''; if (isThreadHref(href)) { if (e.ctrlKey || e.metaKey || e.altKey) return; e.preventDefault(); loadPreview(a, { scrollBehavior: 'smooth', markRead: true, cancelPendingLocate: true }); } return; } const row = e.target.closest('.listdata'); if (!row) return; const threadLink = getThreadLinkInRow(row); if (!threadLink) return; e.preventDefault(); loadPreview(threadLink, { scrollBehavior: 'smooth', markRead: true, cancelPendingLocate: true }); }); leftScroll.addEventListener('scroll', () => { scheduleAutoLoadCheck(0); }, { passive: true }); // ========= 快捷键 ========= const boundDocs = new WeakSet(); const boundHotkeyWindows = new WeakSet(); let ctrlShiftOnlyCandidate = false; let ctrlShiftOnlyUsedOtherKey = false; function resetCtrlShiftOnlyState() { ctrlShiftOnlyCandidate = false; ctrlShiftOnlyUsedOtherKey = false; } function isEditableTarget(target) { if (!target) return false; if (target.isContentEditable) return true; const tag = target.tagName ? target.tagName.toLowerCase() : ''; if (['input', 'textarea', 'select', 'button'].includes(tag)) return true; if (target.closest) { const editable = target.closest( 'input, textarea, select, button, [contenteditable=""], [contenteditable="true"], [role="textbox"]' ); if (editable) return true; } return false; } function isModifierKeyName(key) { return key === 'Control' || key === 'Shift' || key === 'Alt' || key === 'Meta'; } function handleHotkeyKeyDown(e) { if (e.defaultPrevented) return; const key = e.key || ''; const editable = isEditableTarget(e.target); const modifierKey = isModifierKeyName(key); if (ctrlShiftOnlyCandidate) { if (e.altKey || e.metaKey) { resetCtrlShiftOnlyState(); } else if (!modifierKey) { ctrlShiftOnlyUsedOtherKey = true; } } if (!editable && !e.altKey && !e.metaKey) { if ((key === 'Control' || key === 'Shift') && e.ctrlKey && e.shiftKey && !e.repeat) { ctrlShiftOnlyCandidate = true; ctrlShiftOnlyUsedOtherKey = false; } } if (editable) return; const isSpace = e.code === 'Space' || e.key === ' ' || e.key === 'Spacebar'; if (!isSpace) return; if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); navigateRelative(-1); return; } e.preventDefault(); e.stopPropagation(); navigateRelative(1); } function handleHotkeyKeyUp(e) { const key = e.key || ''; if (key === 'Alt' || key === 'Meta') { resetCtrlShiftOnlyState(); return; } if ((key === 'Control' || key === 'Shift') && ctrlShiftOnlyCandidate) { const editable = isEditableTarget(e.target); const shouldTrigger = !editable && !ctrlShiftOnlyUsedOtherKey && !e.altKey && !e.metaKey; resetCtrlShiftOnlyState(); if (shouldTrigger) { e.preventDefault(); e.stopPropagation(); jumpToTopFirstThread(); } return; } if (!e.ctrlKey || !e.shiftKey) { resetCtrlShiftOnlyState(); } } function bindHotkeyWindowReset(win) { if (!win || boundHotkeyWindows.has(win)) return; win.addEventListener('blur', resetCtrlShiftOnlyState, true); boundHotkeyWindows.add(win); } function bindHotkeys(doc) { if (!doc || boundDocs.has(doc)) return; doc.addEventListener('keydown', handleHotkeyKeyDown, true); doc.addEventListener('keyup', handleHotkeyKeyUp, true); boundDocs.add(doc); bindHotkeyWindowReset(doc.defaultView || window); } function bindHotkeysToIframe() { try { const doc = iframe.contentDocument; if (doc) bindHotkeys(doc); } catch (_) {} } bindHotkeys(document); // ========= iframe 同步 ========= iframe.addEventListener('load', () => { let doc = null; let actualHref = ''; try { doc = iframe.contentDocument; actualHref = iframe.contentWindow.location.href; iframe.dataset.currentUrl = actualHref; } catch (_) {} if (doc) { simplifyIframeDocument(doc); bindHotkeys(doc); } else { bindHotkeysToIframe(); } try { const path = iframe.contentWindow.location.pathname; const match = path.match(/\/bbs-(\d+)\.html/i); if (!match) { updateToolbarState(); return; } const id = match[1]; const titleFromDoc = extractThreadTitleFromDoc(doc); const link = findLinkByThreadId(id); if (link) { markLinkAsRead(link, { save: true }); setActive(link, { scrollBehavior: false, saveLast: true, urlOverride: actualHref || resolveThreadUrl(link), titleOverride: titleFromDoc || normalizeSpace(link.textContent || '') }); } else { setVirtualActiveThread({ id, url: actualHref || buildThreadUrlById(id), title: titleFromDoc, clearHighlight: true, saveLast: true }); } updateToolbarState(); } catch (_) { updateToolbarState(); } }); // ========= 工具栏按钮 ========= btnPrev.addEventListener('click', () => { navigateRelative(-1); }); btnNext.addEventListener('click', () => { navigateRelative(1); }); btnNewTab.addEventListener('click', () => { openCurrentInNewTab(); }); btnSaveImg.addEventListener('click', async () => { await exportCurrentPreviewAsPng(); }); btnClear.addEventListener('click', () => { clearTraceAndRefresh(); }); // ========= 分隔条交互 ========= let activePointerId = null; let pointerDown = false; let armed = false; let dragging = false; let holdTimer = null; let dragRaf = 0; let lastKnownClientX = 0; let startX = 0; let startY = 0; let grabOffsetX = 0; let isPointerOverDivider = false; let suppressHoverUntilLeave = false; function beginHover() { if (suppressHoverUntilLeave || dragging) return; divider.classList.add('is-hovering'); } function endHover() { divider.classList.remove('is-hovering'); } function clearHoldTimer() { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } } function applyClientX(clientX) { const rect = root.getBoundingClientRect(); const desiredLeft = clientX - rect.left - (DIVIDER_HIT_WIDTH / 2) - grabOffsetX; setLeftPx(desiredLeft, { save: false }); } function scheduleApply(clientX) { lastKnownClientX = clientX; if (dragRaf) return; dragRaf = requestAnimationFrame(() => { dragRaf = 0; applyClientX(lastKnownClientX); }); } function startDragging(clientX) { if (dragging) return; dragging = true; root.classList.add('is-dragging'); divider.classList.add('is-dragging', 'is-armed'); divider.classList.remove('is-arming'); document.documentElement.classList.add('yh-split-dragging'); updateRatioBadge(); scheduleApply(clientX); } function resetDividerToDefault() { divider.classList.remove('is-arming', 'is-armed', 'is-dragging', 'is-hovering'); } function finishInteraction(saveWidth = true, forceBackToDefault = false) { clearHoldTimer(); if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = 0; if (dragging && Number.isFinite(lastKnownClientX)) { applyClientX(lastKnownClientX); } } if (dragging && saveWidth) { saveCurrentRatio(); } pointerDown = false; armed = false; if (dragging) { dragging = false; root.classList.remove('is-dragging'); document.documentElement.classList.remove('yh-split-dragging'); suppressHoverUntilLeave = true; resetDividerToDefault(); } else { divider.classList.remove('is-arming', 'is-armed'); if (forceBackToDefault) { suppressHoverUntilLeave = true; resetDividerToDefault(); } else if (isPointerOverDivider && !suppressHoverUntilLeave) { beginHover(); } else { endHover(); } } if (activePointerId != null) { try { if (divider.hasPointerCapture?.(activePointerId)) { divider.releasePointerCapture(activePointerId); } } catch (_) {} } activePointerId = null; } divider.addEventListener('contextmenu', (e) => e.preventDefault()); divider.addEventListener('pointerenter', () => { isPointerOverDivider = true; if (!pointerDown) beginHover(); }); divider.addEventListener('pointerleave', () => { isPointerOverDivider = false; if (!pointerDown && !dragging) endHover(); suppressHoverUntilLeave = false; }); divider.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse' && e.button !== 0) return; e.preventDefault(); activePointerId = e.pointerId; pointerDown = true; armed = false; dragging = false; startX = e.clientX; startY = e.clientY; lastKnownClientX = e.clientX; const rootRect = root.getBoundingClientRect(); const leftWidth = leftPane.getBoundingClientRect().width; const dividerCenterX = rootRect.left + leftWidth + DIVIDER_HIT_WIDTH / 2; grabOffsetX = e.clientX - dividerCenterX; suppressHoverUntilLeave = false; beginHover(); divider.classList.add('is-arming'); divider.classList.remove('is-armed', 'is-dragging'); try { divider.setPointerCapture?.(e.pointerId); } catch (_) {} clearHoldTimer(); holdTimer = setTimeout(() => { if (!pointerDown || activePointerId !== e.pointerId) return; armed = true; divider.classList.add('is-armed'); divider.classList.remove('is-arming'); }, HOLD_TO_DRAG_MS); }); window.addEventListener('pointermove', (e) => { if (!pointerDown || activePointerId !== e.pointerId) return; lastKnownClientX = e.clientX; if (!armed) { const moved = Math.hypot(e.clientX - startX, e.clientY - startY); if (moved > ARM_MOVE_TOLERANCE) { clearHoldTimer(); divider.classList.remove('is-arming'); } return; } if (!dragging) { startDragging(e.clientX); } e.preventDefault(); scheduleApply(e.clientX); }, { passive: false }); window.addEventListener('pointerup', (e) => { if (activePointerId == null || activePointerId !== e.pointerId) return; finishInteraction(true, true); }); window.addEventListener('pointercancel', (e) => { if (activePointerId == null || activePointerId !== e.pointerId) return; finishInteraction(true, true); }); window.addEventListener('blur', () => { if (pointerDown || dragging) { finishInteraction(true, true); } }); divider.addEventListener('dblclick', (e) => { e.preventDefault(); if (pointerDown || dragging) return; clearHoldTimer(); divider.classList.remove('is-arming', 'is-armed', 'is-dragging'); root.classList.remove('is-dragging'); document.documentElement.classList.remove('yh-split-dragging'); setLeftPx(getUsableWidth() * DEFAULT_LEFT_RATIO, { save: true }); }); window.addEventListener('resize', () => { if (dragging) { applyClientX(lastKnownClientX); updateRatioBadge(); return; } applySavedRatio(); }); // ========= 初始化 ========= async function initialize() { applyReadMarks(); knownThreadCount = getListThreadLinks().length; lastLoadMoreText = getLoadMoreText(); applySavedRatio(); updateRatioBadge(); const restored = await restoreLastViewedThreadOnOpen(); if (!restored) { loadPreview(firstLink, { scrollBehavior: 'auto', markRead: true, forceReload: false, cancelPendingLocate: false }); } scheduleAutoLoadCheck(180); requestAnimationFrame(() => { root.classList.remove('is-initializing'); }); } initialize().catch((err) => { console.warn('[yaohuo split preview] 初始化失败,回退到首帖:', err); try { loadPreview(firstLink, { scrollBehavior: 'auto', markRead: true, forceReload: false, cancelPendingLocate: false }); } catch (_) {} requestAnimationFrame(() => { root.classList.remove('is-initializing'); }); }); window.addEventListener('beforeunload', () => { cancelPendingRestoreLocate(); clearAutoLoadTimers(); resetCtrlShiftOnlyState(); hideToast(); }); })();