// ==UserScript== // @name 妖火论坛-帖子列表页双栏预览 // @namespace https://github.com/WuSanJu/YaoHuo_Split_Preview // @version 5.1.0 // @description 桌面端专用:左边原帖列表,右边 iframe 预览;支持拖拽分栏、已读记录、上一帖/下一帖、新标签、导出右侧为PNG、清痕迹、恢复上次浏览帖子、左栏自动加载更多、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/master/YaoHuo_Split_Preview.js // @updateURL https://raw.githubusercontent.com/WuSanJu/YaoHuo_Split_Preview/master/YaoHuo_Split_Preview.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_desktop_left_ratio_v5'; const STORAGE_READ_KEY = 'yaohuo_split_desktop_read_threads_v5'; const STORAGE_LAST_THREAD_MAP_KEY = 'yaohuo_split_desktop_last_thread_map_v5'; 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; const HTML2CANVAS_URL = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'; 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; const PREVIEW_EARLY_CLEAN_TIMEOUT = 2500; const PREVIEW_EARLY_CLEAN_POLL_MS = 30; // 左侧列表“按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 ShortcutConfig = Object.freeze({ c: Object.freeze({ type: 'quickReply', texts: Object.freeze(['吃肉', '吃吃', '吃了']) }), x: Object.freeze({ type: 'quickReply', texts: Object.freeze(['谢谢分享', '感谢分享', '多谢分享']) }), space: Object.freeze({ type: 'navigate', delta: 1 }), 'shift+space': Object.freeze({ type: 'navigate', delta: -1 }) }); const LoadTextRules = Object.freeze({ preview: Object.freeze({ trigger: PREVIEW_LOADMORE_TRIGGER_RE, done: PREVIEW_LOADMORE_DONE_RE, loading: PREVIEW_LOADMORE_LOADING_RE }), left: Object.freeze({ trigger: /加载更多|点击加载|更多|下一页/i, done: /没有更多|已全部加载|全部加载完成|全部加载|到底了|末页|最后一页|加载完毕|结束/i, loading: /加载中|正在|请稍候|loading/i }) }); function isUnsupportedClient() { const ua = navigator.userAgent || ''; const coarsePointer = window.matchMedia?.('(pointer: coarse)').matches; const narrowViewport = Math.min(window.innerWidth || 0, window.innerHeight || 0) < 760; return /Android|iPhone|iPad|iPod|Mobile|Windows Phone/i.test(ua) || (coarsePointer && narrowViewport); } function showUnsupportedNotice() { document.documentElement.innerHTML = ` 脚本不适配移动端
脚本不适配移动端
`; } if (isUnsupportedClient()) { showUnsupportedNotice(); return; } // ========= 共享工具模块 ========= 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 toAbsoluteUrl(`/bbs-${safeId}.html`, location.origin); } function resolveThreadUrl(link) { return toAbsoluteUrl(link.getAttribute('href') || '', location.origin); } function getThreadLinkInRow(row) { return Array.from(row.querySelectorAll('a[href]')).find(a => { const href = a.getAttribute('href') || ''; return isThreadHref(href); }) || null; } function createThreadMeta({ link = null, id = '', url = '', title = '' } = {}) { const safeId = String(id || (link ? getThreadIdFromLink(link) : '') || '').trim(); const safeUrl = normalizeSpace(url || (link ? resolveThreadUrl(link) : buildThreadUrlById(safeId))); const safeTitle = normalizeSpace(title || getThreadTitleFromLink(link)); return Object.freeze({ link, id: safeId, url: safeUrl, title: safeTitle }); } function normalizeSpace(text) { return String(text || '').replace(/\s+/g, ' ').trim(); } function getThreadTitleFromLink(link) { return normalizeSpace(link?.textContent || ''); } 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 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 createJsonStorage(key, fallbackFactory) { return { load() { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallbackFactory(); } catch (_) { return fallbackFactory(); } }, save(value) { localStorage.setItem(key, JSON.stringify(value)); } }; } function createSetStorage(key) { const storage = createJsonStorage(key, () => []); let value = new Set(); return { load() { const arr = storage.load(); value = new Set(Array.isArray(arr) ? arr.map(String) : []); return value; }, save() { storage.save([...value]); } }; } function createBoundedRecordStorage(key, { limit, isValid, sortValue }) { const storage = createJsonStorage(key, () => ({})); const normalize = (record) => { const entries = Object.entries(record || {}).filter(([, value]) => isValid(value)); if (entries.length <= limit) return Object.fromEntries(entries); entries.sort((a, b) => sortValue(b[1]) - sortValue(a[1])); return Object.fromEntries(entries.slice(0, limit)); }; return { load() { const data = storage.load(); return data && typeof data === 'object' && !Array.isArray(data) ? data : {}; }, save(record) { storage.save(normalize(record)); } }; } function createNumberStorage(key, fallbackValue, { min = -Infinity, max = Infinity } = {}) { const clamp = (value) => Math.max(min, Math.min(max, value)); return { load() { const value = parseFloat(localStorage.getItem(key) || String(fallbackValue)); return Number.isFinite(value) ? clamp(value) : fallbackValue; }, save(value) { if (!Number.isFinite(value)) return; localStorage.setItem(key, String(clamp(value).toFixed(4))); } }; } function dispatchElementClick(el, win = window) { if (!el) return false; try { el.click(); return true; } catch (_) {} try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: win || window })); return true; } catch (_) {} return false; } function createActionStateReader({ rules, selectors = [], collectExtraCandidates = null }) { const build = (control, text = '') => { const normalizedText = normalizeSpace(text || getElementActionText(control)); const finished = !!rules.done?.test(normalizedText); const loading = !!rules.loading?.test(normalizedText); const actionable = !!rules.trigger?.test(normalizedText) && !finished && !loading; return { control: control || null, text: normalizedText, finished, loading, actionable }; }; const read = (doc) => { if (!doc) return build(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 = build(el, text); if (!state.actionable && !state.loading && !state.finished) return; candidates.push(state); }; for (const selector of selectors) { doc.querySelectorAll(selector).forEach(el => pushCandidate(el)); } if (typeof collectExtraCandidates === 'function') { collectExtraCandidates(doc, pushCandidate); } return ( candidates.find(item => item.actionable) || candidates.find(item => item.loading) || candidates.find(item => item.finished) || build(null, '') ); }; return Object.freeze({ build, read }); } async function waitForActionProgress({ readState, getMetric, prevMetric, prevText, timeout, pollMs, isCancelled = null, isMetricAdvanced = null, formatAdvancedReason = null }) { const start = Date.now(); let sawLoading = !!readState().loading; while (Date.now() - start < timeout) { if (typeof isCancelled === 'function' && isCancelled()) { return { cancelled: true, state: readState(), metric: getMetric(), reason: 'cancelled' }; } const state = readState(); const metric = getMetric(); if (state.loading) { sawLoading = true; } if (state.finished) { return { timeout: false, state, metric, reason: 'finished' }; } if (typeof isMetricAdvanced === 'function' && isMetricAdvanced(metric, prevMetric)) { const reason = typeof formatAdvancedReason === 'function' ? formatAdvancedReason(metric, prevMetric) : 'metric-grown'; return { timeout: false, state, metric, reason }; } 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(pollMs); } return { timeout: true, state: readState(), metric: getMetric(), reason: 'timeout' }; } function createActionCycle({ getBusyPromise, setBusyPromise, clearBusyPromise, shouldCancel, getSnapshot, getControl, isControlVisible = (control) => isElementActuallyVisible(control), isFinished, isBusy, waitProgress, normalizeProgress, clickControl = (control) => dispatchElementClick(control), getCooldownRemaining = null, onCooldown = null, onClick = null, onFinally = null }) { return (options = {}) => { const busyPromise = getBusyPromise(); if (busyPromise) return busyPromise; let promise = null; promise = (async () => { if (typeof shouldCancel === 'function' && shouldCancel(options)) { return { cancelled: true, status: 'cancelled' }; } const snapshot = getSnapshot(options); const control = getControl(snapshot, options); if (!control || !isControlVisible(control, snapshot, options)) { return { status: 'no-control', clicked: false, ...snapshot, finished: isFinished(snapshot.text, snapshot, options) }; } if (isFinished(snapshot.text, snapshot, options)) { return { status: 'finished', clicked: false, ...snapshot, finished: true }; } if (isBusy(snapshot.text, snapshot, options)) { const progress = await waitProgress(snapshot, options); return normalizeProgress(progress, false, snapshot, options); } const cooldownRemaining = typeof getCooldownRemaining === 'function' ? getCooldownRemaining(snapshot, options) : 0; if (cooldownRemaining > 0) { if (typeof onCooldown === 'function') onCooldown(cooldownRemaining, snapshot, options); return { status: 'cooldown', clicked: false, ...snapshot, remaining: cooldownRemaining }; } if (!clickControl(control, snapshot, options)) { return { status: 'click-failed', clicked: false, ...snapshot }; } if (typeof onClick === 'function') onClick(snapshot, options); const progress = await waitProgress(snapshot, options); return normalizeProgress(progress, true, snapshot, options); })().finally(() => { if (getBusyPromise() === promise) clearBusyPromise(); if (typeof onFinally === 'function') onFinally(options); }); setBusyPromise(promise); return promise; }; } function scrollIntoViewCenter(el) { try { el?.scrollIntoView?.({ block: 'center', inline: 'nearest' }); } catch (_) {} } function scrollTop(container, behavior = 'smooth') { try { container.scrollTo({ top: 0, behavior }); } catch (_) { container.scrollTop = 0; } } function centerInside(container, target, behavior = 'smooth') { if (!container || !target) return; const containerRect = container.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const targetTop = container.scrollTop + (targetRect.top - containerRect.top) - (containerRect.height / 2 - targetRect.height / 2); const maxTop = Math.max(0, container.scrollHeight - container.clientHeight); const safeTop = Math.max(0, Math.min(maxTop, targetTop)); container.scrollTo({ top: safeTop, behavior }); } function clickCentered(el, win = window) { if (!el) return false; scrollIntoViewCenter(el); return dispatchElementClick(el, win); } function firstBySelectors(doc, selectors, predicate = null) { if (!doc) return null; for (const selector of selectors) { const found = Array.from(doc.querySelectorAll(selector)).find(el => ( typeof predicate === 'function' ? predicate(el) : true )); if (found) return found; } return null; } const PreviewLoadMoreReader = createActionStateReader({ rules: LoadTextRules.preview, selectors: [ '#KL_loadmore', '.reply-load-more', '.load-more', '.loadmore', '[data-action="loadmore"]', '.more a', '.more button', '.more input[type="button"]', '.more input[type="submit"]' ], collectExtraCandidates(doc, pushCandidate) { 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); }); } }); const LeftLoadMoreReader = createActionStateReader({ rules: LoadTextRules.left, selectors: [], collectExtraCandidates(doc, pushCandidate) { const loadMore = doc.querySelector('#KL_loadmore'); const tip = doc.querySelector('#KL_show_tip'); if (loadMore || tip) { pushCandidate(loadMore || tip, `${tip ? tip.textContent : ''} ${loadMore ? loadMore.textContent : ''}`); } } }); 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); } // ========= 当前页面判定 ========= const rows = Array.from(document.querySelectorAll('.listdata')); if (!rows.length) return; const firstLink = rows.map(getThreadLinkInRow).find(Boolean); if (!firstLink) return; // ========= 存储模块 ========= const LastViewedStorage = createBoundedRecordStorage(STORAGE_LAST_THREAD_MAP_KEY, { limit: LAST_THREAD_MAP_LIMIT, isValid: (value) => value && typeof value === 'object' && value.id, sortValue: (value) => Number(value.ts) || 0 }); const ReadThreadStorage = createSetStorage(STORAGE_READ_KEY); const RatioStorage = createNumberStorage(STORAGE_RATIO_KEY, DEFAULT_LEFT_RATIO, { min: 0.1, max: 0.9 }); const readSet = ReadThreadStorage.load(); function getListContextKey() { 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 loadLastViewedThreadState() { const map = LastViewedStorage.load(); const value = map[getListContextKey()]; 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 map = LastViewedStorage.load(); map[getListContextKey()] = { id, url: normalizeSpace(state.url || buildThreadUrlById(id)), title: normalizeSpace(state.title || ''), ts: Date.now() }; LastViewedStorage.save(map); } // ========= 样式 ========= 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; scrollbar-width: none; -ms-overflow-style: none; box-sizing: border-box; background: #fff; scroll-behavior: smooth; } #yh-left-scroll::-webkit-scrollbar { width: 0; height: 0; display: none; } #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: 16px; line-height: 1.35; font-weight: 800; color: #0f766e; letter-spacing: .18px; text-align: left; user-select: text; -webkit-user-select: text; padding: 5px 12px 5px 28px; border: 1px solid rgba(45, 212, 191, .42); border-radius: 14px; background: linear-gradient(90deg, rgba(240, 253, 250, .98) 0%, rgba(236, 253, 245, .88) 52%, rgba(239, 246, 255, .74) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,.9), 0 5px 16px rgba(20, 184, 166, .12); text-shadow: 0 1px 0 rgba(255,255,255,.95); } #yh-current-title::before { content: ''; position: absolute; left: 11px; top: 50%; width: 10px; height: 10px; border-radius: 50%; transform: translateY(-50%); background: linear-gradient(180deg, #5eead4 0%, #14b8a6 100%); box-shadow: 0 0 0 4px rgba(45, 212, 191, .18), 0 0 18px rgba(20, 184, 166, .45); } #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; 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); } `; 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 currentTitleEl = root.querySelector('#yh-current-title'); for (const node of originalNodes) { if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SCRIPT') continue; leftScroll.appendChild(node); } // ========= 运行状态模块 ========= const AppState = { current: { link: null, id: '', url: '', title: '' }, exportPngBusy: false, restoreLocateSeq: 0, restoreLocateBusy: false, cancelRestoreLocate() { this.restoreLocateSeq += 1; this.restoreLocateBusy = false; }, beginRestoreLocate() { this.restoreLocateSeq += 1; this.restoreLocateBusy = true; return this.restoreLocateSeq; }, isRestoreTaskCurrent(taskSeq) { return taskSeq == null || taskSeq === this.restoreLocateSeq; }, finishRestoreLocate() { this.restoreLocateBusy = false; }, setCurrent(thread = {}) { const meta = createThreadMeta(thread); this.current.link = meta.link; this.current.id = meta.id; this.current.url = meta.url; this.current.title = meta.title; return meta; } }; // ========= 反馈模块 ========= const FeedbackModule = { toastTimer: null, show(message, duration = 1600) { clearTimeout(this.toastTimer); toast.textContent = message; toast.classList.add('show'); if (duration > 0) { this.toastTimer = setTimeout(() => { toast.classList.remove('show'); }, duration); } else { this.toastTimer = null; } }, hide() { clearTimeout(this.toastTimer); this.toastTimer = null; toast.classList.remove('show'); } }; const CurrentThreadModule = { save() { if (!AppState.current.id) return; saveLastViewedThreadState({ id: AppState.current.id, url: AppState.current.url, title: AppState.current.title }); }, setVirtual({ id = '', url = '', title = '', clearHighlight = true, saveLast = true } = {}) { AppState.setCurrent(createThreadMeta({ id, url, title: title || AppState.current.title })); if (clearHighlight) ThreadListModule.clearActive(); if (saveLast) this.save(); updateToolbarState(); }, setFromLink(link, { scrollBehavior = 'smooth', saveLast = true, urlOverride = '', titleOverride = '' } = {}) { if (!link) return; const thread = createThreadMeta({ link, url: urlOverride || '', title: titleOverride || '' }); AppState.setCurrent({ ...thread, title: thread.title || AppState.current.title }); ThreadListModule.highlight(link, { scrollBehavior }); updateToolbarState(); if (saveLast) this.save(); } }; const ThreadListModule = { getLinks() { return Array.from(leftScroll.querySelectorAll('.listdata')) .map(getThreadLinkInRow) .filter(Boolean); }, findById(id) { const targetId = String(id || ''); return this.getLinks().find(link => getThreadIdFromLink(link) === targetId) || null; }, setReadClass(link, isRead) { if (!link) return; link.classList.add('yh-thread-link'); link.classList.toggle('yh-thread-read', !!isRead); }, applyReadMarks() { for (const link of this.getLinks()) { const id = getThreadIdFromLink(link); this.setReadClass(link, id && readSet.has(id)); } }, markRead(link, { save = true } = {}) { if (!link) return; const id = getThreadIdFromLink(link); this.setReadClass(link, !!id); if (!id) return; if (!readSet.has(id)) { readSet.add(id); if (save) ReadThreadStorage.save(); } this.setReadClass(link, true); }, clearActive() { leftScroll.querySelectorAll('.yh-preview-active').forEach(el => { el.classList.remove('yh-preview-active'); }); }, highlight(link, { scrollBehavior = false } = {}) { this.clearActive(); if (!link) return; const row = link.closest('.listdata'); if (!row) return; row.classList.add('yh-preview-active'); if (scrollBehavior) { centerInside(leftScroll, row, scrollBehavior === true ? 'smooth' : scrollBehavior); } } }; function getCurrentListContext() { const links = ThreadListModule.getLinks(); let index = -1; if (AppState.current.link) { index = links.indexOf(AppState.current.link); } if (index < 0 && AppState.current.id) { const found = links.find(link => getThreadIdFromLink(link) === AppState.current.id); if (found) { AppState.current.link = found; index = links.indexOf(found); } } return { links, index }; } function updateToolbarState() { const { links, index } = getCurrentListContext(); const hasCurrent = !!(AppState.current.url || AppState.current.id); btnPrev.disabled = AppState.exportPngBusy || index <= 0; btnNext.disabled = AppState.exportPngBusy || index < 0 || index >= links.length - 1; btnNewTab.disabled = AppState.exportPngBusy || !hasCurrent; btnSaveImg.disabled = AppState.exportPngBusy || !hasCurrent; btnClear.disabled = AppState.exportPngBusy; const title = getThreadTitleFromLink(AppState.current.link) || AppState.current.title || (AppState.current.id ? `帖子 ID ${AppState.current.id}` : '未选择帖子'); currentTitleEl.textContent = title; currentTitleEl.title = title; } function refreshCurrentHighlight({ scrollBehavior = false } = {}) { if (!AppState.current.id) { ThreadListModule.clearActive(); AppState.current.link = null; updateToolbarState(); return null; } const link = ThreadListModule.findById(AppState.current.id); AppState.current.link = link || null; if (!link) { ThreadListModule.clearActive(); updateToolbarState(); return null; } if (!AppState.current.title) { AppState.current.title = getThreadTitleFromLink(link); } ThreadListModule.highlight(link, { scrollBehavior }); updateToolbarState(); return link; } 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) { AppState.cancelRestoreLocate(); } const url = resolveThreadUrl(link); if (markRead) { ThreadListModule.markRead(link, { save: true }); } CurrentThreadModule.setFromLink(link, { scrollBehavior, saveLast: true, urlOverride: url, titleOverride: getThreadTitleFromLink(link) }); if (forceReload || iframe.dataset.currentUrl !== url) { PreviewFrameModule.navigate(url); } } function reloadCurrentPreview() { const targetUrl = AppState.current.url || buildThreadUrlById(AppState.current.id); if (!targetUrl) return; try { if (iframe.contentWindow && iframe.contentWindow.location) { iframe.contentWindow.location.reload(); PreviewFrameModule.startEarlyClean(targetUrl); return; } } catch (_) {} PreviewFrameModule.navigate(targetUrl, { force: true }); } 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) { FeedbackModule.show('已经是第一帖'); updateToolbarState(); return; } if (nextIndex >= links.length) { FeedbackModule.show('已经是最后一帖'); updateToolbarState(); return; } loadPreview(links[nextIndex], { scrollBehavior: 'smooth', markRead: true, forceReload: false, cancelPendingLocate: true }); } function openCurrentInNewTab() { const url = AppState.current.url || buildThreadUrlById(AppState.current.id); if (!url) return; window.open(url, '_blank', 'noopener,noreferrer'); } function activateRestoredLink(link, { savedUrl = '', savedTitle = '', scrollBehavior = 'auto' } = {}) { if (!link) return false; ThreadListModule.markRead(link, { save: true }); CurrentThreadModule.setFromLink(link, { scrollBehavior, saveLast: true, urlOverride: savedUrl, titleOverride: savedTitle || getThreadTitleFromLink(link) }); if (savedUrl && iframe.dataset.currentUrl !== savedUrl) { PreviewFrameModule.navigate(savedUrl); } return true; } function clearTraceAndRefresh() { AppState.cancelRestoreLocate(); const pageLinks = ThreadListModule.getLinks(); const pageIds = new Set( pageLinks.map(link => getThreadIdFromLink(link)).filter(Boolean) ); const keepId = AppState.current.id ? String(AppState.current.id) : ''; let removedCount = 0; for (const id of pageIds) { if (id !== keepId && readSet.has(id)) { readSet.delete(id); removedCount++; } } if (keepId) { readSet.add(keepId); } ReadThreadStorage.save(); ThreadListModule.applyReadMarks(); if (AppState.current.link) { AppState.current.link.classList.add('yh-thread-link'); AppState.current.link.classList.add('yh-thread-read'); } refreshCurrentHighlight({ scrollBehavior: false }); reloadCurrentPreview(); FeedbackModule.show(removedCount ? `已清除本页 ${removedCount} 条痕迹` : '本页没有可清除痕迹'); } function jumpToTopFirstThread() { AppState.cancelRestoreLocate(); const links = ThreadListModule.getLinks(); const topLink = links[0] || firstLink; if (!topLink) { FeedbackModule.show('未找到顶部第一个帖子'); return; } loadPreview(topLink, { scrollBehavior: false, markRead: true, forceReload: false, cancelPendingLocate: true }); scrollTop(leftScroll, 'smooth'); FeedbackModule.show('已回到顶部并打开第一个帖子', 1500); } // ========= iframe 正文净化 ========= const PreviewCleanCache = new WeakMap(); function simplifyIframeDocument(doc, { force = false } = {}) { if (!doc || !doc.body) return false; const body = doc.body; const styleId = 'yh-preview-clean-style'; if (!doc.getElementById(styleId)) { const cleanStyle = doc.createElement('style'); cleanStyle.id = styleId; cleanStyle.textContent = ` *, *::before, *::after { box-sizing: border-box !important; } html { background: #fff !important; height: 100% !important; overflow-x: hidden !important; overflow-y: auto !important; } body { background: #fff !important; width: 100% !important; max-width: 100% !important; min-height: 100% !important; margin: 0 !important; padding: 0 !important; overflow: visible !important; word-break: break-word !important; overflow-wrap: anywhere !important; } [data-yh-preview-hidden="1"] { display: none !important; } .content, .tipmini, .louzhuxinxi, .viewContent { width: 100% !important; max-width: 100% !important; margin-left: 0 !important; margin-right: 0 !important; } .content { padding-top: 0 !important; } img, video, canvas, iframe, embed, object { max-width: 100% !important; height: auto !important; } table { width: 100% !important; max-width: 100% !important; table-layout: fixed !important; border-collapse: collapse; } td, th { max-width: 100% !important; word-break: break-word !important; overflow-wrap: anywhere !important; } pre, code, textarea { max-width: 100% !important; white-space: pre-wrap !important; word-break: break-word !important; overflow-wrap: anywhere !important; } input, select, button { max-width: 100% !important; } `; (doc.head || doc.documentElement).appendChild(cleanStyle); } const children = Array.from(body.children); const firstChild = children[0] || null; const lastChild = children[children.length - 1] || null; const cached = PreviewCleanCache.get(body); if ( !force && cached && cached.count === children.length && cached.firstChild === firstChild && cached.lastChild === lastChild ) { return false; } const visibleSet = new Set(children); const mainStart = children.find(el => el.classList && el.classList.contains('content')); if (mainStart) { let node = body.firstElementChild; while (node && node !== mainStart) { visibleSet.delete(node); 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) { visibleSet.delete(node); node = node.nextElementSibling; } } for (const el of children) { const shouldHide = !visibleSet.has(el); if (shouldHide) { if (el.getAttribute('data-yh-preview-hidden') !== '1') { el.setAttribute('data-yh-preview-hidden', '1'); } } else if (el.hasAttribute('data-yh-preview-hidden')) { el.removeAttribute('data-yh-preview-hidden'); } } PreviewCleanCache.set(body, { count: children.length, firstChild, lastChild }); return true; } let html2CanvasPromise = null; function loadHtml2Canvas() { if (window.html2canvas) return Promise.resolve(window.html2canvas); if (html2CanvasPromise) return html2CanvasPromise; html2CanvasPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = HTML2CANVAS_URL; script.async = true; script.onload = () => { if (window.html2canvas) resolve(window.html2canvas); else reject(new Error('截图引擎加载失败')); }; script.onerror = () => reject(new Error('截图引擎加载失败')); document.head.appendChild(script); }).catch(err => { html2CanvasPromise = null; throw err; }); return html2CanvasPromise; } 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 }; } async function runPreviewLoadMoreCycle({ doc, win, state, metric }) { return createActionCycle({ getBusyPromise: () => null, setBusyPromise: () => {}, clearBusyPromise: () => {}, getSnapshot: () => ({ text: state.text, metric, state }), getControl: (snapshot) => snapshot.state.control, isFinished: (_text, snapshot) => snapshot.state.finished, isBusy: (_text, snapshot) => snapshot.state.loading, waitProgress: (snapshot) => waitForActionProgress({ readState: () => PreviewLoadMoreReader.read(doc), getMetric: () => getPreviewLoadMetrics(doc), prevMetric: snapshot.metric, prevText: snapshot.text, timeout: PREVIEW_LOADMORE_CYCLE_TIMEOUT, pollMs: PREVIEW_LOADMORE_POLL_MS, isMetricAdvanced: (nextMetric, prevMetric) => ( nextMetric.replyCount > prevMetric.replyCount || nextMetric.replyHeight > prevMetric.replyHeight + 24 ) }), normalizeProgress: (progress, clicked) => ({ progress, clicked }), clickControl: (control) => clickCentered(control, win) })(); } async function expandAllPreviewReplies(doc, win) { 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, { force: true }); const state = PreviewLoadMoreReader.read(doc); const metric = getPreviewLoadMetrics(doc); if (!state.control) { return { status: everFound || clicks > 0 ? 'done' : 'not-found', clicks, metric, text: '' }; } everFound = true; if (state.finished) { state.control.setAttribute('data-yh-preview-hidden', '1'); return { status: 'done', clicks, metric, text: state.text }; } if (!state.actionable && !state.loading) { return { status: clicks > 0 ? 'done' : 'not-found', clicks, metric, text: state.text }; } if (state.actionable) clicks += 1; FeedbackModule.show(`正在展开评论${clicks ? `(第 ${clicks} 次,当前约 ${metric.replyCount} 条)` : ''}...`, 0); const { progress } = await runPreviewLoadMoreCycle({ doc, win, state, metric }); simplifyIframeDocument(doc, { force: true }); 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: PreviewLoadMoreReader.read(doc).text }; } function formatExpandResult(result) { const count = result?.metric?.replyCount; const countText = Number.isFinite(count) && count > 0 ? `,当前约 ${count} 条回复` : ''; if (!result || result.status === 'not-found') return '未发现更多评论,开始生成图片...'; if (result.status === 'done') return `评论已展开完成${countText},开始生成图片...`; if (result.status === 'limit') return `评论较多,已达到展开上限${countText},开始生成图片...`; if (result.status === 'timeout') return `评论展开等待超时${countText},开始生成图片...`; return '开始生成图片...'; } async function waitForPreviewAssets(doc, timeout = PNG_IMAGE_TIMEOUT) { const imageTasks = Array.from(doc.images || []).map(img => { try { img.loading = 'eager'; } catch (_) {} if (img.complete) return typeof img.decode === 'function' ? img.decode().catch(() => {}) : Promise.resolve(); return new Promise(resolve => { let timer = 0; const finish = () => { if (timer) clearTimeout(timer); img.removeEventListener('load', finish); img.removeEventListener('error', finish); resolve(); }; img.addEventListener('load', finish, { once: true }); img.addEventListener('error', finish, { once: true }); timer = setTimeout(finish, timeout); }); }); const fontTask = doc.fonts?.ready ? doc.fonts.ready.catch(() => {}) : Promise.resolve(); await Promise.race([ Promise.allSettled([fontTask, ...imageTasks]), delay(timeout) ]); } function toAbsoluteUrl(url, base = location.href) { return new URL(url || '', base).href; } const EXPORT_SAFE_STYLE_PROPS = [ 'display', 'box-sizing', 'float', 'clear', 'flex-direction', 'flex-wrap', 'flex-flow', 'justify-content', 'align-items', 'align-content', 'align-self', 'flex-grow', 'flex-shrink', 'flex-basis', 'gap', 'row-gap', 'column-gap', 'grid-template-columns', 'grid-template-rows', 'grid-auto-flow', 'grid-auto-columns', 'grid-auto-rows', 'grid-column-start', 'grid-column-end', 'grid-row-start', 'grid-row-end', 'width', 'max-width', 'min-width', 'height', 'min-height', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', 'border-radius', 'background-color', 'background-image', 'background-size', 'background-repeat', 'background-position', 'color', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-variant', 'font-stretch', 'line-height', 'letter-spacing', 'text-align', 'text-indent', 'text-decoration', 'text-transform', 'white-space', 'word-break', 'overflow-wrap', 'vertical-align', 'direction', 'list-style-type', 'list-style-position', 'border-collapse', 'table-layout', 'caption-side', 'empty-cells', 'object-fit', 'object-position', 'opacity', 'z-index', 'transform', 'transform-origin' ]; function copySafeComputedStyles(source, target, win, depth = 0) { if (!source || !target || source.nodeType !== Node.ELEMENT_NODE || target.nodeType !== Node.ELEMENT_NODE) return; const style = win.getComputedStyle(source); for (const prop of EXPORT_SAFE_STYLE_PROPS) { const value = style.getPropertyValue(prop); if (value) target.style.setProperty(prop, value); } const position = style.getPropertyValue('position'); if (depth === 0) { target.style.setProperty('position', 'relative', 'important'); target.style.setProperty('left', 'auto', 'important'); target.style.setProperty('right', 'auto', 'important'); target.style.setProperty('top', 'auto', 'important'); target.style.setProperty('bottom', 'auto', 'important'); target.style.setProperty('transform', 'none', 'important'); } else { target.style.setProperty('position', position === 'fixed' ? 'absolute' : (position || 'static')); ['left', 'right', 'top', 'bottom'].forEach(prop => { const value = style.getPropertyValue(prop); if (value) target.style.setProperty(prop, value); }); } target.style.setProperty('zoom', '1', 'important'); if ((source.tagName || '').toLowerCase() === 'canvas') { try { const dataUrl = source.toDataURL('image/png'); if (dataUrl) { const img = target.ownerDocument.createElement('img'); img.src = dataUrl; img.style.cssText = target.style.cssText; target.replaceWith(img); return; } } catch (_) {} } const sourceChildren = Array.from(source.children || []); const targetChildren = Array.from(target.children || []); for (let i = 0; i < sourceChildren.length; i++) { copySafeComputedStyles(sourceChildren[i], targetChildren[i], win, depth + 1); } } function buildPngExportTarget(doc, width) { const host = document.createElement('div'); host.id = 'yh-png-export-host'; host.style.cssText = [ 'position:fixed', 'left:-100000px', 'top:0', `width:${width}px`, 'background:#fff', 'z-index:-1', 'pointer-events:none', 'overflow:visible' ].join(';'); host.innerHTML = `
`; const root = host.querySelector('#yh-png-export-root'); const win = doc.defaultView || window; Array.from(doc.body?.children || []).forEach(child => { if (!child || child.getAttribute('data-yh-preview-hidden') === '1') return; if (/^(script|style|link|noscript)$/i.test(child.tagName || '')) return; const clone = child.cloneNode(true); copySafeComputedStyles(child, clone, win); root.appendChild(clone); }); root.querySelectorAll('[src]').forEach(el => { const src = el.getAttribute('src') || ''; if (src) el.setAttribute('src', toAbsoluteUrl(src, doc.baseURI)); el.removeAttribute('srcset'); }); root.querySelectorAll('[href]').forEach(el => { const href = el.getAttribute('href') || ''; if (href && !href.startsWith('#')) el.setAttribute('href', toAbsoluteUrl(href, doc.baseURI)); }); document.body.appendChild(host); return { host, root, release() { host.remove(); } }; } async function exportCurrentPreviewAsPng() { if (AppState.exportPngBusy) return; const targetUrl = AppState.current.url || buildThreadUrlById(AppState.current.id); if (!targetUrl) { FeedbackModule.show('当前没有可导出的帖子'); return; } const oldText = btnSaveImg.textContent; let win = null; let canvas = null; let exportTarget = null; let prevScrollX = 0; let prevScrollY = 0; const setStage = (label) => { btnSaveImg.textContent = label; }; const setBusy = (busy, label = '') => { AppState.exportPngBusy = busy; if (label) btnSaveImg.textContent = label; updateToolbarState(); }; setBusy(true, '准备导出...'); try { const doc = iframe.contentDocument; win = iframe.contentWindow; if (!doc || !win || !doc.body || !doc.documentElement) throw new Error('预览页面尚未加载完成'); prevScrollX = win.scrollX || 0; prevScrollY = win.scrollY || 0; setStage('展开评论...'); simplifyIframeDocument(doc, { force: true }); const expandResult = await expandAllPreviewReplies(doc, win); FeedbackModule.show(formatExpandResult(expandResult), 0); setStage('加载资源...'); try { win.scrollTo(0, 0); } catch (_) {} await settleFrames(win, 2); await waitForPreviewAssets(doc); setStage('生成图片...'); const html2canvas = await loadHtml2Canvas(); const html = doc.documentElement; const body = doc.body; const exportWidth = Math.max( Math.ceil(iframe.clientWidth || win.innerWidth || 0), Math.ceil(html.clientWidth || 0), Math.ceil(body.clientWidth || 0), 1 ); exportTarget = buildPngExportTarget(doc, exportWidth); await settleFrames(window, 2); const exportSize = { width: exportWidth, height: Math.max( Math.ceil(exportTarget.root.scrollHeight || 0), Math.ceil(exportTarget.root.offsetHeight || 0), 1 ) }; const baseScale = Math.max(2, Number(win?.devicePixelRatio) || Number(window.devicePixelRatio) || 1); const scaleByPixels = Math.sqrt(PNG_MAX_TOTAL_PIXELS / Math.max(1, exportSize.width * exportSize.height)); const scaleBySide = Math.min( PNG_MAX_SIDE / Math.max(1, exportSize.width), PNG_MAX_SIDE / Math.max(1, exportSize.height) ); const exportScale = Math.min(baseScale, scaleByPixels, scaleBySide); canvas = await html2canvas(exportTarget.root, { backgroundColor: '#fff', scale: Number.isFinite(exportScale) && exportScale > 0 ? Math.max(PNG_MIN_SCALE, exportScale) : 1, useCORS: true, allowTaint: false, logging: false, scrollX: 0, scrollY: 0, windowWidth: exportSize.width, windowHeight: exportSize.height, width: exportSize.width, height: exportSize.height }); setStage('保存PNG...'); const blob = await new Promise((resolve, reject) => { canvas.toBlob(result => { if (result) resolve(result); else reject(new Error('PNG 数据为空')); }, 'image/png'); }); const title = AppState.current.title || getThreadTitleFromLink(AppState.current.link) || extractThreadTitleFromDoc(doc) || doc?.title || '妖火帖子'; const id = AppState.current.id || getThreadIdFromHref(AppState.current.url || '') || '未知ID'; const now = new Date(); const padTime = (n) => String(n).padStart(2, '0'); const timestamp = `${now.getFullYear()}${padTime(now.getMonth() + 1)}${padTime(now.getDate())}-${padTime(now.getHours())}${padTime(now.getMinutes())}${padTime(now.getSeconds())}`; const filename = `${sanitizeFileName(title)}_${sanitizeFileName(id)}_${timestamp}.PNG`; const downloadUrl = URL.createObjectURL(blob); const downloadLink = document.createElement('a'); downloadLink.href = downloadUrl; downloadLink.download = filename; downloadLink.rel = 'noopener noreferrer'; downloadLink.style.display = 'none'; document.body.appendChild(downloadLink); downloadLink.click(); downloadLink.remove(); setTimeout(() => URL.revokeObjectURL(downloadUrl), 15000); FeedbackModule.show(`已导出 PNG:${filename}`, 3200); } catch (err) { console.error('[yaohuo split preview] 导出 PNG 失败:', err); const msg = String(err?.message || err || ''); if (/taint|cross-origin|security|Tainted canvases/i.test(msg)) { FeedbackModule.show('导出失败:页面里有防盗链/跨域图片,浏览器不允许写入 PNG', 3200); } else if (/截图引擎加载失败/i.test(msg)) { FeedbackModule.show('导出失败:截图引擎加载失败,请检查网络后重试', 3200); } else if (/预览页面尚未加载完成/i.test(msg)) { FeedbackModule.show('导出失败:右侧预览还没加载完,请稍后再试', 2600); } else { FeedbackModule.show('导出失败:请稍后重试,或换一帖再试', 2600); } } finally { try { if (win) win.scrollTo(prevScrollX, prevScrollY); } catch (_) {} if (canvas) { try { canvas.width = 0; canvas.height = 0; } catch (_) {} } if (exportTarget) exportTarget.release(); btnSaveImg.textContent = oldText; setBusy(false); } } // ========= 布局尺寸模块 ========= const LayoutModule = { getUsableWidth() { return Math.max(0, root.clientWidth - DIVIDER_HIT_WIDTH); }, updateRatioBadge() { const usable = Math.max(1, this.getUsableWidth()); const leftWidth = leftPane.getBoundingClientRect().width; const leftPercent = (leftWidth / usable) * 100; const rightPercent = Math.max(0, 100 - leftPercent); const formatPercent = (v) => { const rounded = Math.round(v); return Math.abs(v - rounded) < 0.05 ? `${rounded}%` : `${v.toFixed(1)}%`; }; 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`; }, saveCurrentRatio() { const usable = this.getUsableWidth(); if (usable <= 0) return; const leftWidth = leftPane.getBoundingClientRect().width; const ratio = leftWidth / usable; RatioStorage.save(ratio); }, setLeftPx(px, { save = false } = {}) { const usable = this.getUsableWidth(); let safePx = 0; if (usable > 0) { let minLeft = MIN_LEFT; let maxLeft = usable - MIN_RIGHT; if (usable <= MIN_LEFT + MIN_RIGHT) { const fallback = Math.round(usable * DEFAULT_LEFT_RATIO); minLeft = Math.min(usable, Math.max(0, fallback)); maxLeft = minLeft; } safePx = Math.max(minLeft, Math.min(px, maxLeft)); } leftPane.style.flex = `0 0 ${safePx}px`; this.updateRatioBadge(); if (save) this.saveCurrentRatio(); }, applySavedRatio() { const usable = this.getUsableWidth(); if (usable <= 0) return; const saved = RatioStorage.load(); this.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; const LeftLoadMoreModule = { parseProgress(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 }; }, read() { const state = LeftLoadMoreReader.read(leftScroll.ownerDocument || document); const control = leftScroll.querySelector('#KL_loadmore') || state.control; const progress = this.parseProgress(state.text); const finished = state.finished || (progress && progress.current >= progress.total); return { ...state, control, finished, actionable: !!control && isElementActuallyVisible(control) && !finished && !state.loading }; }, isBusy(text = '') { return LoadTextRules.left.loading.test(text); }, isFinished(text = '') { const progress = this.parseProgress(text); return !!(progress && progress.current >= progress.total) || LoadTextRules.left.done.test(text); }, clearTimers() { if (autoLoadCheckTimer) { clearTimeout(autoLoadCheckTimer); autoLoadCheckTimer = 0; } if (autoLoadCheckRaf) { cancelAnimationFrame(autoLoadCheckRaf); autoLoadCheckRaf = 0; } }, schedule(delayMs = 0) { this.clearTimers(); const run = () => { autoLoadCheckRaf = requestAnimationFrame(() => { autoLoadCheckRaf = 0; this.maybeAutoLoad(); }); }; if (delayMs > 0) { autoLoadCheckTimer = setTimeout(() => { autoLoadCheckTimer = 0; run(); }, delayMs); return; } run(); }, async waitProgress({ prevCount, prevText, timeout = AUTO_LOAD_FALLBACK_MS, taskSeq } = {}) { const progress = await waitForActionProgress({ readState: () => this.read(), getMetric: () => ({ count: ThreadListModule.getLinks().length }), prevMetric: { count: prevCount }, prevText, timeout, pollMs: LEFT_LOADMORE_POLL_MS, isCancelled: () => !AppState.isRestoreTaskCurrent(taskSeq), isMetricAdvanced: (metric, prevMetric) => metric.count > prevMetric.count, formatAdvancedReason: () => 'count-grown' }); const text = progress.state?.text || this.read().text; const count = progress.metric?.count ?? ThreadListModule.getLinks().length; if (progress.cancelled) return { cancelled: true, count, text }; if (progress.timeout) return { timeout: true, count, text }; return { changed: true, count, text, reason: progress.reason === 'loading-finished' ? 'busy-settled' : progress.reason }; }, normalizeProgressResult(progress, clicked) { if (progress.cancelled) return { cancelled: true, status: 'cancelled' }; if (progress.timeout) { return { status: 'timeout', clicked, count: progress.count, text: progress.text, progressReason: 'timeout' }; } return { status: progress.reason === 'count-grown' ? 'loaded' : (this.isFinished(progress.text) ? 'finished' : 'settled'), clicked, count: progress.count, text: progress.text, progressReason: progress.reason }; }, runCycle: null, maybeAutoLoad() { if (AppState.restoreLocateBusy) return; if (leftScroll.scrollTop + leftScroll.clientHeight < leftScroll.scrollHeight - AUTO_LOAD_THRESHOLD) return; const state = this.read(); if (!state.control || this.isFinished(state.text)) return; this.runCycle({ timeout: AUTO_LOAD_FALLBACK_MS, ignoreCooldown: false }).catch(() => {}); } }; LeftLoadMoreModule.runCycle = createActionCycle({ getBusyPromise: () => leftLoadMorePromise, setBusyPromise: (promise) => { leftLoadMorePromise = promise; }, clearBusyPromise: () => { leftLoadMorePromise = null; }, shouldCancel: ({ taskSeq } = {}) => !AppState.isRestoreTaskCurrent(taskSeq), getSnapshot: () => ({ ...LeftLoadMoreModule.read(), count: ThreadListModule.getLinks().length }), getControl: (snapshot) => snapshot.control, isFinished: (text) => LeftLoadMoreModule.isFinished(text), isBusy: (text) => LeftLoadMoreModule.isBusy(text), waitProgress: (snapshot, { timeout = AUTO_LOAD_FALLBACK_MS, taskSeq } = {}) => ( LeftLoadMoreModule.waitProgress({ prevCount: snapshot.count, prevText: snapshot.text, timeout, taskSeq }) ), normalizeProgress: (progress, clicked) => LeftLoadMoreModule.normalizeProgressResult(progress, clicked), getCooldownRemaining: (_snapshot, { ignoreCooldown = false } = {}) => { const elapsedSinceLastClick = Date.now() - leftLoadMoreLastClickAt; return !ignoreCooldown && elapsedSinceLastClick < AUTO_LOAD_COOLDOWN_MS ? AUTO_LOAD_COOLDOWN_MS - elapsedSinceLastClick : 0; }, onCooldown: (remaining) => { LeftLoadMoreModule.schedule(remaining + 40); }, onClick: () => { leftLoadMoreLastClickAt = Date.now(); }, onFinally: () => { LeftLoadMoreModule.schedule(90); } }); function handleLeftListMutation() { mutationRaf = 0; const threadCount = ThreadListModule.getLinks().length; const loadMoreText = LeftLoadMoreModule.read().text; const countChanged = threadCount !== knownThreadCount; const textChanged = loadMoreText !== lastLoadMoreText; if (countChanged) knownThreadCount = threadCount; if (textChanged) lastLoadMoreText = loadMoreText; ThreadListModule.applyReadMarks(); refreshCurrentHighlight({ scrollBehavior: false }); if (countChanged || textChanged) { updateToolbarState(); LeftLoadMoreModule.schedule(90); } } // ========= 左栏按帖子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 (!AppState.isRestoreTaskCurrent(taskSeq)) { return { cancelled: true, link: null, clicks }; } const found = ThreadListModule.findById(threadId); if (found) { return { status: 'found', link: found, clicks }; } const loadState = LeftLoadMoreModule.read(); const loadMoreLink = loadState.control; const loadText = loadState.text; if (!loadMoreLink || LeftLoadMoreModule.isFinished(loadText)) { return { status: 'exhausted', link: null, clicks, text: loadText }; } if (!LeftLoadMoreModule.isBusy(loadText)) { if (clicks >= RESTORE_LOCATE_MAX_CLICKS) { return { status: 'limit', link: null, clicks, text: loadText }; } clicks += 1; FeedbackModule.show(`正在定位上次浏览帖子(ID ${threadId}),自动加载更多中… 第 ${clicks} 次`, 0); } else { FeedbackModule.show(`正在定位上次浏览帖子(ID ${threadId}),等待列表加载…`, 0); } const cycle = await LeftLoadMoreModule.runCycle({ timeout: RESTORE_LOCATE_WAIT_TIMEOUT, ignoreCooldown: true, taskSeq }); if (!AppState.isRestoreTaskCurrent(taskSeq)) { return { cancelled: true, link: null, clicks }; } const foundAfter = ThreadListModule.findById(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 || LeftLoadMoreModule.read().text }; } if (cycle.status === 'cooldown') { await delay(Math.min(500, Math.max(80, Number(cycle.remaining) || 120))); continue; } if (cycle.status === 'timeout') { const currentState = LeftLoadMoreModule.read(); const currentText = currentState.text; if (!currentState.control || LeftLoadMoreModule.isFinished(currentText)) { return { status: 'exhausted', link: null, clicks, text: currentText }; } continue; } } return { status: 'limit', link: ThreadListModule.findById(threadId), clicks, text: LeftLoadMoreModule.read().text }; } async function restoreLastViewedThreadOnOpen() { const last = loadLastViewedThreadState(); if (!last || !last.id) { return false; } const taskSeq = AppState.beginRestoreLocate(); const savedId = String(last.id); const savedUrl = normalizeSpace(last.url || buildThreadUrlById(savedId)); const savedTitle = normalizeSpace(last.title || ''); CurrentThreadModule.setVirtual({ id: savedId, url: savedUrl, title: savedTitle, clearHighlight: true, saveLast: false }); if (savedUrl) { PreviewFrameModule.navigate(savedUrl); } const immediateLink = ThreadListModule.findById(savedId); if (immediateLink) { if (!AppState.isRestoreTaskCurrent(taskSeq)) return true; activateRestoredLink(immediateLink, { savedUrl, savedTitle }); AppState.finishRestoreLocate(); FeedbackModule.show('已恢复到上次浏览帖子', 1400); return true; } FeedbackModule.show(`正在恢复上次浏览帖子(ID ${savedId})…`, 0); try { const result = await locateThreadInLeftListById(savedId, { taskSeq }); if (!AppState.isRestoreTaskCurrent(taskSeq)) { return true; } AppState.finishRestoreLocate(); if (result.cancelled) { return true; } if (result.link) { activateRestoredLink(result.link, { savedUrl, savedTitle }); if (result.clicks > 0) { FeedbackModule.show(`已恢复上次浏览帖子(自动加载更多 ${result.clicks} 次)`, 1800); } else { FeedbackModule.show('已恢复到上次浏览帖子', 1400); } return true; } updateToolbarState(); FeedbackModule.show(`右侧已恢复帖子,左侧未找到 ID ${savedId}`, 3000); return true; } catch (err) { AppState.finishRestoreLocate(); console.warn('[yaohuo split preview] 恢复上次浏览帖子失败:', err); updateToolbarState(); FeedbackModule.show('恢复上次浏览帖子失败,已保留右侧帖子页面', 2600); return true; } } // ========= 左侧点击逻辑 ========= function handleLeftListClick(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 }); } // ========= 快捷回复 ========= function isEditableReplyControl(el) { if (!el || !isElementActuallyVisible(el)) return false; if (el.isContentEditable) return true; const tag = (el.tagName || '').toLowerCase(); if (tag === 'textarea') return !el.disabled && !el.readOnly; if (tag !== 'input') return false; const type = String(el.getAttribute('type') || 'text').toLowerCase(); return ['text', 'search', ''].includes(type) && !el.disabled && !el.readOnly; } function findReplyEditor(doc) { const selectors = [ 'textarea[name*="content" i]', 'textarea[name*="reply" i]', 'textarea[id*="content" i]', 'textarea[id*="reply" i]', 'textarea', '[contenteditable="true"]', '[role="textbox"]', 'input[type="text"][name*="content" i]', 'input[type="text"][name*="reply" i]', 'input[type="text"]' ]; return firstBySelectors(doc, selectors, isEditableReplyControl); } function findReplySubmitControl(doc) { const selectors = [ 'input[type="submit"]', 'button[type="submit"]', 'input[type="button"]', 'button', 'a[href*="reply" i]', '[role="button"]' ]; return firstBySelectors(doc, selectors, (el) => { if (!isElementActuallyVisible(el) || el.disabled) return false; const text = normalizeSpace(getElementActionText(el) || el.getAttribute('title') || ''); return /回复|发表|提交|发送|回帖|确定|submit|send/i.test(text); }); } function setReplyEditorValue(editor, text) { if (!editor) return false; const win = editor.ownerDocument?.defaultView || window; const tag = (editor.tagName || '').toLowerCase(); editor.focus(); if (editor.isContentEditable) { editor.textContent = text; } else if (tag === 'textarea' || tag === 'input') { editor.value = text; try { editor.setSelectionRange(text.length, text.length); } catch (_) {} } else { return false; } editor.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: text })); editor.dispatchEvent(new Event('change', { bubbles: true })); scrollIntoViewCenter(editor); if (win.getSelection && editor.isContentEditable) { const range = editor.ownerDocument.createRange(); range.selectNodeContents(editor); range.collapse(false); const selection = win.getSelection(); selection.removeAllRanges(); selection.addRange(range); } return true; } function fillQuickReply(groupKey = 'c') { const doc = PreviewFrameModule.getDocument(); const editor = findReplyEditor(doc); if (!editor) { FeedbackModule.show('未找到右侧回复编辑框', 1800); return false; } const texts = ShortcutConfig[groupKey]?.texts || ShortcutConfig.c.texts; const text = texts[Math.floor(Math.random() * texts.length)] || texts[0]; if (!setReplyEditorValue(editor, text)) { FeedbackModule.show('填入回复失败', 1800); return false; } const submitControl = findReplySubmitControl(doc); if (submitControl) { scrollIntoViewCenter(submitControl); try { submitControl.focus?.(); } catch (_) {} } FeedbackModule.show(`已填入快捷回复:${text}`, 1400); return true; } function getKeyToken(e) { if (e.code && /^Key[A-Z]$/.test(e.code)) { return e.code.slice(-1).toLowerCase(); } if (e.code === 'Space' || e.key === ' ' || e.key === 'Spacebar') { return 'space'; } return String(e.key || '').toLowerCase(); } function getKeyChord(e) { const token = getKeyToken(e); return e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey ? `shift+${token}` : token; } function consumeKeyboardEvent(e) { e.preventDefault(); e.stopPropagation(); } // ========= 快捷键 ========= const HotkeyModule = { boundDocs: new WeakSet(), boundWindows: new WeakSet(), ctrlShiftOnlyCandidate: false, ctrlShiftOnlyUsedOtherKey: false, reset() { this.ctrlShiftOnlyCandidate = false; this.ctrlShiftOnlyUsedOtherKey = false; }, 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; return !!target.closest?.( 'input, textarea, select, button, [contenteditable=""], [contenteditable="true"], [role="textbox"]' ); }, handleKeyDown(e) { if (e.defaultPrevented) return; const key = e.key || ''; const editable = this.isEditableTarget(e.target); const modifierKey = key === 'Control' || key === 'Shift' || key === 'Alt' || key === 'Meta'; if (this.ctrlShiftOnlyCandidate) { if (e.altKey || e.metaKey) this.reset(); else if (!modifierKey) this.ctrlShiftOnlyUsedOtherKey = true; } if (!editable && !e.altKey && !e.metaKey && (key === 'Control' || key === 'Shift') && e.ctrlKey && e.shiftKey && !e.repeat) { this.ctrlShiftOnlyCandidate = true; this.ctrlShiftOnlyUsedOtherKey = false; } if (editable) return; if (e.ctrlKey || e.metaKey || e.altKey) return; const chord = getKeyChord(e); const chordConfig = ShortcutConfig[chord]; const token = getKeyToken(e); const config = chordConfig || (!e.shiftKey ? ShortcutConfig[token] : null); const shortcutKey = chordConfig ? chord : token; if (!config || (config.type !== 'quickReply' && config.type !== 'navigate')) return; consumeKeyboardEvent(e); if (config.type === 'quickReply') { fillQuickReply(shortcutKey); } else { navigateRelative(config.delta > 0 ? 1 : -1); } }, handleKeyUp(e) { const key = e.key || ''; if (key === 'Alt' || key === 'Meta') { this.reset(); return; } if ((key === 'Control' || key === 'Shift') && this.ctrlShiftOnlyCandidate) { const shouldTrigger = !this.isEditableTarget(e.target) && !this.ctrlShiftOnlyUsedOtherKey && !e.altKey && !e.metaKey; this.reset(); if (shouldTrigger) { consumeKeyboardEvent(e); jumpToTopFirstThread(); } return; } if (!e.ctrlKey || !e.shiftKey) this.reset(); }, bindWindow(win) { if (!win || this.boundWindows.has(win)) return; win.addEventListener('blur', () => this.reset(), true); this.boundWindows.add(win); }, bindDocument(doc) { if (!doc || this.boundDocs.has(doc)) return; doc.addEventListener('keydown', (e) => this.handleKeyDown(e), true); doc.addEventListener('keyup', (e) => this.handleKeyUp(e), true); this.boundDocs.add(doc); this.bindWindow(doc.defaultView || window); }, bindIframe() { try { this.bindDocument(iframe.contentDocument); } catch (_) {} } }; HotkeyModule.bindDocument(document); // ========= iframe 同步模块 ========= function handleIframeLoad() { const doc = PreviewFrameModule.getDocument(); const actualHref = PreviewFrameModule.getHref(); if (actualHref) iframe.dataset.currentUrl = actualHref; if (doc) { PreviewFrameModule.cleanNow(doc); } else { HotkeyModule.bindIframe(); } try { const path = PreviewFrameModule.getPathname(); const id = getThreadIdFromHref(path); if (!id) { updateToolbarState(); return; } const titleFromDoc = extractThreadTitleFromDoc(doc); const link = ThreadListModule.findById(id); if (link) { ThreadListModule.markRead(link, { save: true }); CurrentThreadModule.setFromLink(link, { scrollBehavior: false, saveLast: true, urlOverride: actualHref || resolveThreadUrl(link), titleOverride: titleFromDoc || getThreadTitleFromLink(link) }); } else { CurrentThreadModule.setVirtual({ id, url: actualHref || buildThreadUrlById(id), title: titleFromDoc, clearHighlight: true, saveLast: true }); } updateToolbarState(); } catch (_) { updateToolbarState(); } } const PreviewFrameModule = { cleanSeq: 0, cleanRaf: 0, observer: null, getDocument() { try { return iframe.contentDocument || null; } catch (_) { return null; } }, getHref() { try { return iframe.contentWindow.location.href || ''; } catch (_) { return ''; } }, getPathname() { try { return iframe.contentWindow.location.pathname || ''; } catch (_) { return ''; } }, isExpectedHref(href, expectedUrl) { if (!expectedUrl || !href || href === 'about:blank') return true; if (href === expectedUrl) return true; const expectedId = getThreadIdFromHref(expectedUrl); const actualId = getThreadIdFromHref(href); return !!expectedId && expectedId === actualId; }, cleanNow(doc = this.getDocument()) { if (!doc || !doc.body) return false; simplifyIframeDocument(doc); HotkeyModule.bindDocument(doc); this.observeDocument(doc); return true; }, scheduleClean(doc = this.getDocument()) { if (this.cleanRaf) return; this.cleanRaf = requestAnimationFrame(() => { this.cleanRaf = 0; this.cleanNow(doc); }); }, observeDocument(doc) { if (!doc || !doc.body || doc.body.dataset.yhPreviewObserved === '1') return; if (this.observer) { this.observer.disconnect(); } doc.body.dataset.yhPreviewObserved = '1'; this.observer = new MutationObserver(() => this.scheduleClean(doc)); this.observer.observe(doc.body, { childList: true, subtree: false }); }, startEarlyClean(targetUrl = '') { const seq = ++this.cleanSeq; const startedAt = Date.now(); const expectedUrl = normalizeSpace(targetUrl || iframe.dataset.currentUrl || ''); const tick = () => { if (seq !== this.cleanSeq) return; const doc = this.getDocument(); const href = normalizeSpace(this.getHref()); if (!this.isExpectedHref(href, expectedUrl) && Date.now() - startedAt < PREVIEW_EARLY_CLEAN_TIMEOUT) { setTimeout(tick, PREVIEW_EARLY_CLEAN_POLL_MS); return; } const cleaned = this.cleanNow(doc); const hasMainContent = !!doc?.querySelector?.('.content, .viewContent'); if (cleaned && hasMainContent) return; if (Date.now() - startedAt >= PREVIEW_EARLY_CLEAN_TIMEOUT) return; setTimeout(tick, PREVIEW_EARLY_CLEAN_POLL_MS); }; tick(); }, navigate(url, { force = false } = {}) { const targetUrl = normalizeSpace(url || ''); if (!targetUrl) return; if (!force && iframe.dataset.currentUrl === targetUrl) return; iframe.dataset.currentUrl = targetUrl; iframe.src = targetUrl; this.startEarlyClean(targetUrl); }, bind() { iframe.addEventListener('load', handleIframeLoad); } }; // ========= 分隔条交互 ========= const DividerModule = { activePointerId: null, pointerDown: false, armed: false, dragging: false, holdTimer: null, dragRaf: 0, lastKnownClientX: 0, startX: 0, startY: 0, grabOffsetX: 0, isPointerOverDivider: false, suppressHoverUntilLeave: false, beginHover() { if (this.suppressHoverUntilLeave || this.dragging) return; divider.classList.add('is-hovering'); }, endHover() { divider.classList.remove('is-hovering'); }, clearHoldTimer() { if (!this.holdTimer) return; clearTimeout(this.holdTimer); this.holdTimer = null; }, applyClientX(clientX) { const rect = root.getBoundingClientRect(); const desiredLeft = clientX - rect.left - (DIVIDER_HIT_WIDTH / 2) - this.grabOffsetX; LayoutModule.setLeftPx(desiredLeft, { save: false }); }, scheduleApply(clientX) { this.lastKnownClientX = clientX; if (this.dragRaf) return; this.dragRaf = requestAnimationFrame(() => { this.dragRaf = 0; this.applyClientX(this.lastKnownClientX); }); }, startDragging(clientX) { if (this.dragging) return; this.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'); LayoutModule.updateRatioBadge(); this.scheduleApply(clientX); }, resetVisualState() { divider.classList.remove('is-arming', 'is-armed', 'is-dragging', 'is-hovering'); }, releasePointer() { if (this.activePointerId == null) return; try { if (divider.hasPointerCapture?.(this.activePointerId)) { divider.releasePointerCapture(this.activePointerId); } } catch (_) {} this.activePointerId = null; }, finish(saveWidth = true, forceBackToDefault = false) { this.clearHoldTimer(); if (this.dragRaf) { cancelAnimationFrame(this.dragRaf); this.dragRaf = 0; if (this.dragging && Number.isFinite(this.lastKnownClientX)) { this.applyClientX(this.lastKnownClientX); } } if (this.dragging && saveWidth) { LayoutModule.saveCurrentRatio(); } this.pointerDown = false; this.armed = false; if (this.dragging) { this.dragging = false; root.classList.remove('is-dragging'); document.documentElement.classList.remove('yh-split-dragging'); this.suppressHoverUntilLeave = true; this.resetVisualState(); } else { divider.classList.remove('is-arming', 'is-armed'); if (forceBackToDefault) { this.suppressHoverUntilLeave = true; this.resetVisualState(); } else if (this.isPointerOverDivider && !this.suppressHoverUntilLeave) { this.beginHover(); } else { this.endHover(); } } this.releasePointer(); }, handlePointerEnter() { this.isPointerOverDivider = true; if (!this.pointerDown) this.beginHover(); }, handlePointerLeave() { this.isPointerOverDivider = false; if (!this.pointerDown && !this.dragging) this.endHover(); this.suppressHoverUntilLeave = false; }, handlePointerDown(e) { if (e.button !== 0) return; e.preventDefault(); this.activePointerId = e.pointerId; this.pointerDown = true; this.armed = false; this.dragging = false; this.startX = e.clientX; this.startY = e.clientY; this.lastKnownClientX = e.clientX; const rootRect = root.getBoundingClientRect(); const leftWidth = leftPane.getBoundingClientRect().width; const dividerCenterX = rootRect.left + leftWidth + DIVIDER_HIT_WIDTH / 2; this.grabOffsetX = e.clientX - dividerCenterX; this.suppressHoverUntilLeave = false; this.beginHover(); divider.classList.add('is-arming'); divider.classList.remove('is-armed', 'is-dragging'); try { divider.setPointerCapture?.(e.pointerId); } catch (_) {} this.clearHoldTimer(); this.holdTimer = setTimeout(() => { if (!this.pointerDown || this.activePointerId !== e.pointerId) return; this.armed = true; divider.classList.add('is-armed'); divider.classList.remove('is-arming'); }, HOLD_TO_DRAG_MS); }, handlePointerMove(e) { if (!this.pointerDown || this.activePointerId !== e.pointerId) return; this.lastKnownClientX = e.clientX; if (!this.armed) { const moved = Math.hypot(e.clientX - this.startX, e.clientY - this.startY); if (moved > ARM_MOVE_TOLERANCE) { this.clearHoldTimer(); divider.classList.remove('is-arming'); } return; } if (!this.dragging) { this.startDragging(e.clientX); } e.preventDefault(); this.scheduleApply(e.clientX); }, handlePointerEnd(e) { if (this.activePointerId == null || this.activePointerId !== e.pointerId) return; this.finish(true, true); }, handleWindowBlur() { if (this.pointerDown || this.dragging) { this.finish(true, true); } }, handleDoubleClick(e) { e.preventDefault(); if (this.pointerDown || this.dragging) return; this.clearHoldTimer(); divider.classList.remove('is-arming', 'is-armed', 'is-dragging'); root.classList.remove('is-dragging'); document.documentElement.classList.remove('yh-split-dragging'); LayoutModule.setLeftPx(LayoutModule.getUsableWidth() * DEFAULT_LEFT_RATIO, { save: true }); }, handleResize() { if (this.dragging) { this.applyClientX(this.lastKnownClientX); LayoutModule.updateRatioBadge(); return; } LayoutModule.applySavedRatio(); }, bind() { divider.addEventListener('contextmenu', (e) => e.preventDefault()); divider.addEventListener('pointerenter', (e) => this.handlePointerEnter(e)); divider.addEventListener('pointerleave', (e) => this.handlePointerLeave(e)); divider.addEventListener('pointerdown', (e) => this.handlePointerDown(e)); window.addEventListener('pointermove', (e) => this.handlePointerMove(e), { passive: false }); window.addEventListener('pointerup', (e) => this.handlePointerEnd(e)); window.addEventListener('pointercancel', (e) => this.handlePointerEnd(e)); window.addEventListener('blur', () => this.handleWindowBlur()); divider.addEventListener('dblclick', (e) => this.handleDoubleClick(e)); window.addEventListener('resize', () => this.handleResize()); } }; // ========= 初始化 ========= function loadInitialPreview() { loadPreview(firstLink, { scrollBehavior: 'auto', markRead: true, forceReload: false, cancelPendingLocate: false }); } async function initialize() { const leftListObserver = new MutationObserver(() => { if (mutationRaf) return; mutationRaf = requestAnimationFrame(handleLeftListMutation); }); leftListObserver.observe(leftScroll, { childList: true, subtree: true, characterData: true }); leftScroll.addEventListener('click', handleLeftListClick); leftScroll.addEventListener('scroll', () => { LeftLoadMoreModule.schedule(0); }, { passive: true }); PreviewFrameModule.bind(); btnPrev.addEventListener('click', () => navigateRelative(-1)); btnNext.addEventListener('click', () => navigateRelative(1)); btnNewTab.addEventListener('click', openCurrentInNewTab); btnSaveImg.addEventListener('click', () => exportCurrentPreviewAsPng()); btnClear.addEventListener('click', clearTraceAndRefresh); DividerModule.bind(); ThreadListModule.applyReadMarks(); knownThreadCount = ThreadListModule.getLinks().length; lastLoadMoreText = LeftLoadMoreModule.read().text; LayoutModule.applySavedRatio(); LayoutModule.updateRatioBadge(); const restored = await restoreLastViewedThreadOnOpen(); if (!restored) { loadInitialPreview(); } LeftLoadMoreModule.schedule(180); requestAnimationFrame(() => { root.classList.remove('is-initializing'); }); } initialize().catch((err) => { console.warn('[yaohuo split preview] 初始化失败,回退到首帖:', err); try { loadInitialPreview(); } catch (_) {} requestAnimationFrame(() => { root.classList.remove('is-initializing'); }); }); window.addEventListener('beforeunload', () => { AppState.cancelRestoreLocate(); LeftLoadMoreModule.clearTimers(); HotkeyModule.reset(); FeedbackModule.hide(); }); })();