// ==UserScript== // @name 百合会论坛阅读增强 // @namespace http://tampermonkey.net/ // @version 2.1.0 // @description 为百合会论坛提供漫画/小说的沉浸式阅读体验,支持多种阅读模式、暗色模式、Material Design风格 // @author bluelightgit // @match https://bbs.yamibo.com/thread-* // @match https://bbs.yamibo.com/forum.php?mod=viewthread* // @icon https://bbs.yamibo.com/favicon.ico // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @connect bbs.yamibo.com // @run-at document-end // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/553075/%E7%99%BE%E5%90%88%E4%BC%9A%E8%AE%BA%E5%9D%9B%E9%98%85%E8%AF%BB%E5%A2%9E%E5%BC%BA.user.js // @updateURL https://update.greasyfork.icu/scripts/553075/%E7%99%BE%E5%90%88%E4%BC%9A%E8%AE%BA%E5%9D%9B%E9%98%85%E8%AF%BB%E5%A2%9E%E5%BC%BA.meta.js // ==/UserScript== (function() { 'use strict'; function normalizeSeriesTitle(rawTitle) { if (!rawTitle) return ''; let title = rawTitle; title = title.replace(/[((]\s*\d+\s*p\s*[))]\s*$/i, ''); title = title.replace(/第[\d一二三四五六七八九十百千万〇零兩两1234567890\.]+[话話章节節回卷篇]/gi, ' '); title = title.replace(/\d+(?:\.\d+)?\s*[话話章节節回卷篇]/gi, ' '); title = title.replace(/\d+(?:\.\d+)?\s*[上下前后前後左右中篇部卷期全完]+(?:\s*[++&和及與并並,,/]\s*[上下前后前後左右中篇部卷期全完]+)+/gi, ' '); title = title.replace(/[((][上下前后前後中全完]+(?:\s*[,,++&和及與并並/]\s*[上下前后前後中全完]+)*[))]/g, ' '); title = title.replace(/\d+(?:\.\d+)?\s*[上下前后前後左右中篇部卷期全完]+/gi, ' '); title = title.replace(/(?:\s+|[-‐‑‒–—―-~~·•_、::])?\d+(?:\.\d+)*(?:\s*[上下前后前後左右中篇部卷期話话节節全完])?\s*$/g, ' '); title = title.replace(/[--—–~~\u2013\u2014\s]+$/g, ' '); title = title.replace(/[\[\]【】()()]/g, ' '); title = title.replace(/\s+/g, ' ').trim(); if (!title) { return rawTitle.trim(); } return title; } function buildSeriesKey(title) { const normalized = normalizeSeriesTitle(title || ''); const base = normalized || (title || '').trim(); return base.toLowerCase(); } function normalizeSearchResultsPerPageValue(rawValue) { const numeric = Number(rawValue); if (!Number.isFinite(numeric)) { return CONFIG.SEARCH_RESULTS_PER_PAGE_DEFAULT; } const clamped = Math.min( CONFIG.SEARCH_RESULTS_PER_PAGE_MAX, Math.max(CONFIG.SEARCH_RESULTS_PER_PAGE_MIN, Math.floor(numeric)) ); return clamped; } // ========================= const CONFIG = { GOLDEN_RATIO: 0.618, DEFAULT_MAIN_WIDTH_RATIO: 0.7, SEARCH_RESULTS_PER_PAGE_DEFAULT: 60, SEARCH_RESULTS_PER_PAGE_MIN: 20, SEARCH_RESULTS_PER_PAGE_MAX: 120, MAX_SEARCH_PAGES: 10, IMAGE_SIZE_THRESHOLD: 100 * 1024, PRELOAD_COUNT: 3, SEARCH_RETRY_DELAY: 10000, STORAGE_KEY: 'yamibo_reader_data', AUTO_OPEN_KEY: 'yamibo_reader_auto_open', // 阅读模式 VIEW_MODES: { SCROLL_DOWN: 'scroll-down', SCROLL_LEFT: 'scroll-left', SCROLL_RIGHT: 'scroll-right', FLIP_LEFT_SINGLE: 'flip-left-single', FLIP_LEFT_DOUBLE: 'flip-left-double', FLIP_RIGHT_SINGLE: 'flip-right-single', FLIP_RIGHT_DOUBLE: 'flip-right-double' } }; // ========================= // SVG 图标库 // ========================= const ICONS = { book: '', bookmark: '', bookmarkFilled: '', settings: '', close: '', search: '', arrowLeft: '', arrowRight: '', chevronsLeft: '', chevronsRight: '', darkMode: '', lightMode: '', viewMode: '', play: '', delete: '' }; const ALL_VIEW_MODES = new Set(Object.values(CONFIG.VIEW_MODES)); const LEGACY_VIEW_MODE_MAP = { 'scroll-ttb': CONFIG.VIEW_MODES.SCROLL_DOWN, 'scroll-ltr': CONFIG.VIEW_MODES.SCROLL_RIGHT, 'scroll-rtl': CONFIG.VIEW_MODES.SCROLL_LEFT, 'page-single': CONFIG.VIEW_MODES.FLIP_RIGHT_SINGLE, 'page-double': CONFIG.VIEW_MODES.FLIP_RIGHT_DOUBLE }; // ========================= // 图片缓存管理 // ========================= class ImageCache { constructor() { this.cache = new Map(); // url -> objectUrl } async load(url) { if (this.cache.has(url)) { return this.cache.get(url); } try { const response = await fetch(url); const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); this.cache.set(url, objectUrl); return objectUrl; } catch (e) { console.error('Failed to load image:', url, e); return url; } } has(url) { return this.cache.has(url); } get(url) { return this.cache.get(url); } clear() { for (const objectUrl of this.cache.values()) { URL.revokeObjectURL(objectUrl); } this.cache.clear(); } } // ========================= // 数据存储管理 // ========================= class DataStore { constructor() { this.data = this.load(); this.ensureStructure(); } load() { const stored = GM_getValue(CONFIG.STORAGE_KEY, '{}'); try { const data = JSON.parse(stored); return data; } catch (e) { console.error('Failed to parse storage data:', e); return { favorites: {}, readingProgress: {}, settings: { darkMode: false, viewMode: CONFIG.VIEW_MODES.SCROLL_DOWN, floatingButtonPosition: null, searchResultsPerPage: CONFIG.SEARCH_RESULTS_PER_PAGE_DEFAULT, sidebarCollapsed: false } }; } } save() { GM_setValue(CONFIG.STORAGE_KEY, JSON.stringify(this.data)); } ensureStructure() { let settingsUpdated = false; if (!this.data.settings) { this.data.settings = { darkMode: false, viewMode: CONFIG.VIEW_MODES.SCROLL_DOWN, floatingButtonPosition: null, searchResultsPerPage: CONFIG.SEARCH_RESULTS_PER_PAGE_DEFAULT, sidebarCollapsed: false }; settingsUpdated = true; } if (!this.data.favorites || typeof this.data.favorites !== 'object') { this.data.favorites = {}; } if (!this.data.readingProgress || typeof this.data.readingProgress !== 'object') { this.data.readingProgress = {}; } if (!this.data.seriesNameOverrides || typeof this.data.seriesNameOverrides !== 'object') { this.data.seriesNameOverrides = {}; } const pos = this.data.settings.floatingButtonPosition; if (pos && (typeof pos !== 'object' || pos.left === undefined || pos.top === undefined || !Number.isFinite(Number(pos.left)) || !Number.isFinite(Number(pos.top)))) { this.data.settings.floatingButtonPosition = null; settingsUpdated = true; } if (!Object.prototype.hasOwnProperty.call(this.data.settings, 'floatingButtonPosition')) { this.data.settings.floatingButtonPosition = null; settingsUpdated = true; } if (!Object.prototype.hasOwnProperty.call(this.data.settings, 'searchResultsPerPage')) { this.data.settings.searchResultsPerPage = CONFIG.SEARCH_RESULTS_PER_PAGE_DEFAULT; settingsUpdated = true; } else { const normalizedPerPage = normalizeSearchResultsPerPageValue(this.data.settings.searchResultsPerPage); if (normalizedPerPage !== this.data.settings.searchResultsPerPage) { this.data.settings.searchResultsPerPage = normalizedPerPage; settingsUpdated = true; } } if (!Object.prototype.hasOwnProperty.call(this.data.settings, 'sidebarCollapsed')) { this.data.settings.sidebarCollapsed = false; settingsUpdated = true; } else if (typeof this.data.settings.sidebarCollapsed !== 'boolean') { this.data.settings.sidebarCollapsed = !!this.data.settings.sidebarCollapsed; settingsUpdated = true; } this.migrateLegacyFavorites(); let favoritesUpdated = false; if (this.data.favorites && typeof this.data.favorites === 'object') { Object.values(this.data.favorites).forEach(series => { if (!series || typeof series !== 'object') { return; } if (!Object.prototype.hasOwnProperty.call(series, 'directoryCount')) { const storedChapters = series.chapters && typeof series.chapters === 'object' ? Object.keys(series.chapters).length : 0; series.directoryCount = storedChapters; favoritesUpdated = true; } else { const numericCount = Number(series.directoryCount); const normalized = Number.isFinite(numericCount) && numericCount >= 0 ? Math.floor(numericCount) : 0; if (series.directoryCount !== normalized) { series.directoryCount = normalized; favoritesUpdated = true; } } }); } if (favoritesUpdated || settingsUpdated) { this.save(); } } migrateLegacyFavorites() { const favorites = this.data.favorites; if (!favorites) return; const legacyEntries = Object.values(favorites).filter(fav => fav && !fav.seriesKey); if (legacyEntries.length === 0) { return; } const migrated = Object.values(favorites).reduce((acc, fav) => { if (fav && fav.seriesKey) { acc[fav.seriesKey] = fav; } return acc; }, {}); legacyEntries.forEach(fav => { const title = fav.title || ''; const seriesTitle = normalizeSeriesTitle(title) || title || '未命名合集'; const seriesKey = buildSeriesKey(title || fav.threadId || String(Date.now())); const timestamp = fav.lastVisited || fav.addedAt || Date.now(); if (!migrated[seriesKey]) { migrated[seriesKey] = { seriesKey, seriesTitle, author: fav.author || '', chapters: {}, latestThreadId: '', latestTitle: '', latestUrl: '', latestFloor: 0, latestTotalFloors: 0, directoryCount: 0, addedAt: fav.addedAt || timestamp, lastVisited: timestamp }; } if (fav.threadId) { migrated[seriesKey].chapters[fav.threadId] = { threadId: fav.threadId, title: fav.title || '', url: fav.url || '', currentFloor: fav.currentFloor || 0, totalFloors: fav.totalFloors || 0, lastVisited: timestamp }; const latest = migrated[seriesKey]; const latestRef = latest.latestThreadId ? latest.chapters[latest.latestThreadId] : null; if (!latestRef || (latestRef.lastVisited || 0) <= timestamp) { latest.latestThreadId = fav.threadId; latest.latestTitle = fav.title || ''; latest.latestUrl = fav.url || ''; latest.latestFloor = fav.currentFloor || 0; latest.latestTotalFloors = fav.totalFloors || 0; latest.lastVisited = timestamp; } const chapterTotal = latest.chapters ? Object.keys(latest.chapters).length : 0; latest.directoryCount = chapterTotal; } }); this.data.favorites = migrated; this.save(); } addOrUpdateFavorite(seriesKey, payload) { if (!seriesKey) return; if (!this.data.favorites) this.data.favorites = {}; const now = Date.now(); if (!this.data.favorites[seriesKey]) { const seriesTitle = payload?.seriesTitle || normalizeSeriesTitle(payload?.chapterTitle || '') || seriesKey; this.data.favorites[seriesKey] = { seriesKey, seriesTitle, author: payload?.author || '', chapters: {}, latestThreadId: '', latestTitle: '', latestUrl: '', latestFloor: 0, latestTotalFloors: 0, directoryCount: Number.isFinite(payload?.directoryCount) && payload.directoryCount >= 0 ? Math.floor(payload.directoryCount) : 0, addedAt: now, lastVisited: now }; } const series = this.data.favorites[seriesKey]; if (!Object.prototype.hasOwnProperty.call(series, 'directoryCount') || !Number.isFinite(Number(series.directoryCount)) || series.directoryCount < 0) { series.directoryCount = 0; } if (Number.isFinite(payload?.directoryCount) && payload.directoryCount >= 0) { const normalizedPayloadCount = Math.floor(payload.directoryCount); if (series.directoryCount < normalizedPayloadCount) { series.directoryCount = normalizedPayloadCount; } } this.updateFavoriteChapter(seriesKey, payload, now, false); this.save(); } updateFavoriteChapter(seriesKey, payload, timestamp = Date.now(), autoSave = true) { if (!seriesKey || !payload || !payload.threadId) return; const series = this.data.favorites && this.data.favorites[seriesKey]; if (!series) return; if (payload.seriesTitle) { series.seriesTitle = payload.seriesTitle; } if (payload.author) { series.author = payload.author; } if (!series.chapters) { series.chapters = {}; } const chapter = { threadId: payload.threadId, title: payload.chapterTitle || payload.title || '', url: payload.url || '', currentFloor: payload.currentFloor || 0, totalFloors: payload.totalFloors || 0, lastVisited: timestamp }; series.chapters[payload.threadId] = chapter; series.lastVisited = timestamp; const latest = series.latestThreadId ? series.chapters[series.latestThreadId] : null; if (!latest || (latest.lastVisited || 0) <= timestamp || series.latestThreadId === payload.threadId) { series.latestThreadId = payload.threadId; series.latestTitle = chapter.title; series.latestUrl = chapter.url; series.latestFloor = chapter.currentFloor; series.latestTotalFloors = chapter.totalFloors; } const totalChapters = series.chapters ? Object.keys(series.chapters).length : 0; const existingDirectoryCount = Number(series.directoryCount); if (!Number.isFinite(existingDirectoryCount) || existingDirectoryCount < totalChapters) { series.directoryCount = totalChapters; } if (autoSave) { this.save(); } } removeFavorite(seriesKey) { if (this.data.favorites && this.data.favorites[seriesKey]) { delete this.data.favorites[seriesKey]; this.save(); } } isSeriesFavorited(seriesKey) { return !!(this.data.favorites && this.data.favorites[seriesKey]); } updateSeriesDirectoryCount(seriesKey, count) { if (!seriesKey) { return; } const series = this.data.favorites && this.data.favorites[seriesKey]; if (!series) { return; } const numericCount = Number(count); if (!Number.isFinite(numericCount) || numericCount < 0) { return; } const normalized = Math.floor(numericCount); if (Number(series.directoryCount) === normalized) { return; } series.directoryCount = normalized; this.save(); } getSeriesDirectoryCount(seriesKey) { const series = this.data.favorites && this.data.favorites[seriesKey]; if (!series) { return 0; } const numericCount = Number(series.directoryCount); return Number.isFinite(numericCount) && numericCount >= 0 ? Math.floor(numericCount) : 0; } getFavorite(seriesKey) { return this.data.favorites && this.data.favorites[seriesKey]; } getSeriesNameOverride(baseKey) { if (!baseKey) { return null; } const overrides = this.data.seriesNameOverrides; if (!overrides || typeof overrides !== 'object') { return null; } const value = overrides[baseKey]; return typeof value === 'string' && value.trim() ? value.trim() : null; } setSeriesNameOverride(baseKey, name) { if (!baseKey) { return; } const trimmed = typeof name === 'string' ? name.trim() : ''; if (!trimmed) { return; } if (!this.data.seriesNameOverrides || typeof this.data.seriesNameOverrides !== 'object') { this.data.seriesNameOverrides = {}; } if (this.data.seriesNameOverrides[baseKey] === trimmed) { return; } this.data.seriesNameOverrides[baseKey] = trimmed; this.save(); } renameFavoriteSeries(oldKey, newKey, newTitle) { if (!oldKey || !newKey || oldKey === newKey) { return; } if (!this.data.favorites || typeof this.data.favorites !== 'object') { return; } const source = this.data.favorites[oldKey]; if (!source) { return; } let target = this.data.favorites[newKey]; if (target && target !== source) { // 合并章节 if (!target.chapters || typeof target.chapters !== 'object') { target.chapters = {}; } if (source.chapters && typeof source.chapters === 'object') { Object.keys(source.chapters).forEach(threadId => { if (!target.chapters[threadId]) { target.chapters[threadId] = source.chapters[threadId]; } }); } // 更新元数据 const timestamps = [target.lastVisited, source.lastVisited].filter(Boolean); target.lastVisited = timestamps.length ? Math.max(...timestamps) : Date.now(); target.addedAt = Math.min(target.addedAt || Date.now(), source.addedAt || Date.now()); if (!target.author && source.author) { target.author = source.author; } const sourceLastVisited = source.lastVisited || 0; const targetLastVisited = target.lastVisited || 0; if ((!target.latestThreadId && source.latestThreadId) || sourceLastVisited >= targetLastVisited) { target.latestThreadId = source.latestThreadId; target.latestTitle = source.latestTitle; target.latestUrl = source.latestUrl; target.latestFloor = source.latestFloor; target.latestTotalFloors = source.latestTotalFloors; } const sourceChapterCount = source.chapters ? Object.keys(source.chapters).length : 0; const targetChapterCount = target.chapters ? Object.keys(target.chapters).length : 0; const sourceDirectoryCount = Number(source.directoryCount); const targetDirectoryCount = Number(target.directoryCount); const normalizedSourceDirectoryCount = Number.isFinite(sourceDirectoryCount) && sourceDirectoryCount >= 0 ? Math.floor(sourceDirectoryCount) : sourceChapterCount; const normalizedTargetDirectoryCount = Number.isFinite(targetDirectoryCount) && targetDirectoryCount >= 0 ? Math.floor(targetDirectoryCount) : targetChapterCount; target.directoryCount = Math.max(normalizedTargetDirectoryCount, normalizedSourceDirectoryCount, targetChapterCount); } else { this.data.favorites[newKey] = source; target = this.data.favorites[newKey]; } delete this.data.favorites[oldKey]; if (target) { target.seriesKey = newKey; if (newTitle) { target.seriesTitle = newTitle; } } this.save(); } getAllFavorites() { if (!this.data.favorites) return []; return Object.values(this.data.favorites); } setProgress(threadId, floor) { if (!this.data.readingProgress) this.data.readingProgress = {}; this.data.readingProgress[threadId] = { floor, timestamp: Date.now() }; this.save(); } getProgress(threadId) { return this.data.readingProgress && this.data.readingProgress[threadId]; } setSetting(key, value) { if (!this.data.settings) this.data.settings = {}; this.data.settings[key] = value; this.save(); } getSetting(key, defaultValue) { return this.data.settings && this.data.settings[key] !== undefined ? this.data.settings[key] : defaultValue; } getFloatingButtonPosition() { const raw = this.getSetting('floatingButtonPosition', null); if (!raw || typeof raw !== 'object') { return null; } const left = Number(raw.left); const top = Number(raw.top); if (!Number.isFinite(left) || !Number.isFinite(top)) { return null; } return { left, top }; } setFloatingButtonPosition(left, top) { if (!Number.isFinite(left) || !Number.isFinite(top)) { return; } const payload = { left: Math.round(left), top: Math.round(top) }; this.setSetting('floatingButtonPosition', payload); } exportData(pretty = true) { const payload = { favorites: this.data.favorites || {}, settings: this.data.settings || {}, readingProgress: this.data.readingProgress || {}, seriesNameOverrides: this.data.seriesNameOverrides || {} }; return JSON.stringify(payload, pretty ? 2 : 0); } importData(jsonInput) { if (!jsonInput) { throw new Error('数据为空'); } let parsed = jsonInput; if (typeof jsonInput === 'string') { try { parsed = JSON.parse(jsonInput); } catch (e) { throw new Error('JSON 解析失败'); } } if (!parsed || typeof parsed !== 'object') { throw new Error('数据格式不正确'); } const nextData = { favorites: typeof parsed.favorites === 'object' && parsed.favorites !== null ? parsed.favorites : {}, settings: typeof parsed.settings === 'object' && parsed.settings !== null ? parsed.settings : {}, readingProgress: typeof parsed.readingProgress === 'object' && parsed.readingProgress !== null ? parsed.readingProgress : {}, seriesNameOverrides: typeof parsed.seriesNameOverrides === 'object' && parsed.seriesNameOverrides !== null ? parsed.seriesNameOverrides : {} }; this.data = { ...this.data, ...nextData }; this.ensureStructure(); this.save(); } } // ========================= // 内容解析器 // ========================= class ContentParser { constructor() { this.threadId = this.getThreadId(); this.threadTitle = this.getThreadTitle(); this.authorUid = this.getAuthorUid(); this.authorName = this.getAuthorName(); this.seriesTitle = normalizeSeriesTitle(this.threadTitle); this.seriesKey = buildSeriesKey(this.threadTitle); } getThreadId() { const href = window.location.href; let match = href.match(/thread-(\d+)-/); if (match) { return match[1]; } try { const url = new URL(href); const tidParam = url.searchParams.get('tid'); if (tidParam) { return tidParam; } } catch (e) { // ignore URL parsing errors and fallback to regex below } match = href.match(/[?&]tid=(\d+)/); return match ? match[1] : null; } getThreadTitle() { const titleElement = document.querySelector('#thread_subject'); return titleElement ? titleElement.textContent.trim() : ''; } getAuthorUid() { const firstPost = document.querySelector('#postlist > div[id^="post_"]'); if (firstPost) { const authorLink = firstPost.querySelector('.favatar .authi a'); if (authorLink) { const href = authorLink.getAttribute('href'); let match = href.match(/uid=(\d+)/); if (!match) { match = href.match(/uid-(\d+)/); } return match ? match[1] : null; } } return null; } getAuthorName() { const firstPost = document.querySelector('#postlist > div[id^="post_"]'); if (firstPost) { const authorLink = firstPost.querySelector('.favatar .authi a'); if (authorLink) { return authorLink.textContent.trim(); } } return ''; } // 获取楼主的所有帖子 getAuthorPosts() { const posts = []; const postElements = document.querySelectorAll('#postlist > div[id^="post_"]'); postElements.forEach((postEl) => { const authorLink = postEl.querySelector('.favatar .authi a'); if (authorLink) { const href = authorLink.getAttribute('href'); let match = href.match(/uid=(\d+)/); if (!match) { match = href.match(/uid-(\d+)/); } if (match && match[1] === this.authorUid) { const postId = postEl.id.replace('post_', ''); const floorNum = this.getFloorNumber(postEl); const content = postEl.querySelector('.t_f, .pcb'); const images = content ? Array.from(content.querySelectorAll('img.zoom, img[id^="aimg_"]')) : []; // 统计图片总数(包括未加载的) const imageUrls = images.map(img => { return img.getAttribute('file') || img.getAttribute('zoomfile') || img.getAttribute('src') || img.getAttribute('data-original') || ''; }).filter(url => url && !url.includes('static/image')); posts.push({ postId, floor: floorNum, element: postEl, content: content, images: imageUrls, imageCount: imageUrls.length }); } } }); return posts; } getFloorNumber(postEl) { const floorElement = postEl.querySelector('.pi strong a em'); if (floorElement) { const text = floorElement.textContent; const match = text.match(/(\d+)/); return match ? parseInt(match[1]) : 1; } const postnumLink = postEl.querySelector('[id^="postnum"]'); if (postnumLink) { const em = postnumLink.querySelector('em'); if (em) { const match = em.textContent.match(/(\d+)/); return match ? parseInt(match[1]) : 1; } } return 1; } extractSeriesName() { return normalizeSeriesTitle(this.threadTitle); } } // ========================= // 阅读模式界面 // ========================= class ReaderUI { constructor(parser, dataStore) { this.parser = parser; this.dataStore = dataStore; this.isReaderMode = false; this.currentFloor = 0; this.currentImageIndex = 0; this.posts = []; this.allImages = []; this.directory = []; this.searchRetryTimer = null; this.readerContainer = null; const storedViewMode = dataStore.getSetting('viewMode', CONFIG.VIEW_MODES.SCROLL_DOWN); this.viewMode = LEGACY_VIEW_MODE_MAP[storedViewMode] || storedViewMode; if (!ALL_VIEW_MODES.has(this.viewMode)) { this.viewMode = CONFIG.VIEW_MODES.SCROLL_DOWN; this.dataStore.setSetting('viewMode', this.viewMode); } else if (this.viewMode !== storedViewMode) { this.dataStore.setSetting('viewMode', this.viewMode); } this.darkMode = dataStore.getSetting('darkMode', false); this.imageCache = new ImageCache(); // 图片缓存 this.scrollHandler = null; this.scrollUpdateScheduled = false; this.currentScrollImageIndex = 0; this.lastFlipDirection = 'next'; this.baseSeriesKey = buildSeriesKey(this.parser.threadTitle); const defaultSeriesName = this.parser.seriesTitle || normalizeSeriesTitle(this.parser.threadTitle) || this.parser.threadTitle || '未命名合集'; const storedSeriesName = this.dataStore.getSeriesNameOverride(this.baseSeriesKey); this.currentSeriesName = (storedSeriesName || defaultSeriesName).trim() || defaultSeriesName; this.seriesTitle = this.currentSeriesName; this.seriesKey = buildSeriesKey(this.currentSeriesName); if (this.seriesKey !== this.baseSeriesKey && this.dataStore.getFavorite && this.dataStore.getFavorite(this.baseSeriesKey)) { this.dataStore.renameFavoriteSeries(this.baseSeriesKey, this.seriesKey, this.currentSeriesName); } const storedMainWidth = parseFloat(this.dataStore.getSetting('mainWidthRatio', CONFIG.DEFAULT_MAIN_WIDTH_RATIO)); this.mainWidthRatio = Number.isFinite(storedMainWidth) ? storedMainWidth : CONFIG.DEFAULT_MAIN_WIDTH_RATIO; this.mainWidthRatio = Math.min(Math.max(this.mainWidthRatio, 0.5), 0.9); this.sidebarCollapsed = !!this.dataStore.getSetting('sidebarCollapsed', false); this.currentDirectoryCount = null; this.createFloatingButton(); this.autoOpenIfRequested(); } createFloatingButton() { const button = document.createElement('div'); button.id = 'yamibo-reader-btn'; button.innerHTML = ICONS.book; button.title = '开启阅读模式'; this.makeDraggable(button); document.body.appendChild(button); this.floatingBtn = button; this.applyFloatingButtonPosition(); this.handleWindowResize = () => this.updateFloatingButtonDockState(); window.addEventListener('resize', this.handleWindowResize, { passive: true }); button.addEventListener('mouseenter', () => this.handleFloatingButtonHover(true)); button.addEventListener('mouseleave', () => this.handleFloatingButtonHover(false)); } applyFloatingButtonPosition() { if (!this.floatingBtn) { return; } const saved = this.dataStore.getFloatingButtonPosition(); const element = this.floatingBtn; if (saved) { element.style.left = `${saved.left}px`; element.style.top = `${saved.top}px`; element.style.right = 'auto'; element.style.bottom = 'auto'; element.style.transform = 'none'; } else { element.style.left = 'auto'; element.style.bottom = 'auto'; element.style.right = '20px'; element.style.top = '50%'; element.style.transform = 'translateY(-50%)'; } this.updateFloatingButtonDockState(); } updateFloatingButtonDockState() { if (!this.floatingBtn) { return; } if (this.floatingBtn.dataset.dockExpanded === '1') { return; } const rect = this.floatingBtn.getBoundingClientRect(); const threshold = 12; const nearLeft = rect.left <= threshold; const nearRight = window.innerWidth - rect.right <= threshold; const isLeftOnly = nearLeft && !nearRight; const isRightOnly = nearRight && !nearLeft; let dockState = ''; if (isLeftOnly) { this.floatingBtn.classList.add('edge-left'); this.floatingBtn.classList.remove('edge-right'); dockState = 'left'; } else if (isRightOnly) { this.floatingBtn.classList.add('edge-right'); this.floatingBtn.classList.remove('edge-left'); dockState = 'right'; } else { this.floatingBtn.classList.remove('edge-left', 'edge-right'); } this.applyFloatingButtonDockOffset(dockState); } applyFloatingButtonDockOffset(direction, options = {}) { const btn = this.floatingBtn; if (!btn) { return; } const { preserveState = false, force = false } = options; const previous = btn.dataset.dockState || ''; if (!direction && preserveState && previous) { // fall through to restoration without clearing state } else if (previous === direction && !force) { return; } const hasStoredLeft = Object.prototype.hasOwnProperty.call(btn.dataset, 'restoreLeft'); const hasStoredRight = Object.prototype.hasOwnProperty.call(btn.dataset, 'restoreRight'); if (!direction) { if (hasStoredLeft) { btn.style.left = btn.dataset.restoreLeft; } if (hasStoredRight) { btn.style.right = btn.dataset.restoreRight; } if (!preserveState) { delete btn.dataset.restoreLeft; delete btn.dataset.restoreRight; btn.dataset.dockState = ''; } return; } if (!hasStoredLeft) { btn.dataset.restoreLeft = btn.style.left || ''; } if (!hasStoredRight) { btn.dataset.restoreRight = btn.style.right || ''; } const halfWidth = Math.round(btn.offsetWidth / 2); if (direction === 'left') { btn.style.left = `${-halfWidth}px`; btn.style.right = 'auto'; } else if (direction === 'right') { btn.style.right = `${-halfWidth}px`; btn.style.left = 'auto'; } btn.dataset.dockState = direction; } handleFloatingButtonHover(isHovering) { const btn = this.floatingBtn; if (!btn) { return; } const dockState = btn.dataset.dockState || ''; if (!dockState) { return; } if (isHovering) { if (btn.dataset.dockExpanded === '1') { return; } this.applyFloatingButtonDockOffset('', { preserveState: true }); btn.classList.add('edge-expanded'); btn.dataset.dockExpanded = '1'; } else { if (btn.dataset.dockExpanded !== '1') { return; } btn.classList.remove('edge-expanded'); delete btn.dataset.dockExpanded; this.applyFloatingButtonDockOffset(dockState, { force: true }); } } makeDraggable(element) { const DRAG_DELAY = 300; let isDragging = false; let dragTimeoutId = null; let pointerDownTime = 0; let pointerId = null; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; const clearDragTimer = () => { if (dragTimeoutId !== null) { clearTimeout(dragTimeoutId); dragTimeoutId = null; } }; const beginDragging = () => { if (isDragging) return; isDragging = true; element.style.transition = 'none'; element.style.transform = 'none'; element.style.left = `${startLeft}px`; element.style.top = `${startTop}px`; element.style.right = 'auto'; element.style.cursor = 'move'; document.body.classList.add('reader-btn-dragging'); element.classList.remove('edge-left', 'edge-right'); if (element.dataset.dockExpanded === '1') { this.handleFloatingButtonHover(false); } }; const endDragging = () => { if (!isDragging) return; isDragging = false; const rect = element.getBoundingClientRect(); this.dataStore.setFloatingButtonPosition(rect.left, rect.top); this.applyFloatingButtonPosition(); element.style.transition = ''; element.style.cursor = ''; document.body.classList.remove('reader-btn-dragging'); this.updateFloatingButtonDockState(); }; element.addEventListener('pointerdown', (e) => { if (typeof e.button === 'number' && e.button !== 0) { return; } pointerDownTime = performance.now(); pointerId = e.pointerId; startX = e.clientX; startY = e.clientY; const rect = element.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; clearDragTimer(); dragTimeoutId = window.setTimeout(() => { if (pointerId !== null) { beginDragging(); } }, DRAG_DELAY); element.setPointerCapture?.(pointerId); e.preventDefault(); }); element.addEventListener('pointermove', (e) => { if (!isDragging) { return; } const deltaX = e.clientX - startX; const deltaY = e.clientY - startY; let newLeft = startLeft + deltaX; let newTop = startTop + deltaY; newLeft = Math.max(0, Math.min(window.innerWidth - element.offsetWidth, newLeft)); newTop = Math.max(0, Math.min(window.innerHeight - element.offsetHeight, newTop)); element.style.left = `${newLeft}px`; element.style.top = `${newTop}px`; element.style.right = 'auto'; }); const handlePointerEnd = (e) => { if (pointerId !== null && element.releasePointerCapture) { try { element.releasePointerCapture(pointerId); } catch (err) { /* ignore */ } } clearDragTimer(); if (isDragging) { endDragging(); } else if (pointerDownTime > 0) { const elapsed = performance.now() - pointerDownTime; if (elapsed < DRAG_DELAY) { this.toggleReaderMode(); } } pointerDownTime = 0; pointerId = null; }; element.addEventListener('pointerup', handlePointerEnd); element.addEventListener('pointercancel', handlePointerEnd); } toggleReaderMode() { if (!this.isReaderMode) { this.enterReaderMode(); } else { this.exitReaderMode(); } } enterReaderMode() { this.isReaderMode = true; this.posts = this.parser.getAuthorPosts(); if (this.posts.length === 0) { alert('未找到楼主的帖子内容'); return; } // 收集所有图片 this.allImages = []; this.posts.forEach(post => { post.images.forEach(img => { this.allImages.push({ url: img, floor: post.floor, loaded: false }); }); }); document.body.classList.add('yamibo-reader-active'); if (this.darkMode) { document.body.classList.add('dark-mode'); } this.createReaderContainer(); const progress = this.dataStore.getProgress(this.parser.threadId); if (progress) { this.currentFloor = progress.floor; } this.renderContent(); this.loadDirectory(); } exitReaderMode() { this.isReaderMode = false; document.body.classList.remove('yamibo-reader-active', 'dark-mode', 'reader-resizing'); this.teardownScrollInteractions(); const container = document.getElementById('yamibo-reader-container'); if (container) { container.classList.remove('resizing'); container.remove(); } this.readerContainer = null; } createReaderContainer() { const currentPreload = this.dataStore.getSetting('preloadCount', CONFIG.PRELOAD_COUNT); const cachedCount = this.imageCache.cache.size; const container = document.createElement('div'); container.id = 'yamibo-reader-container'; container.innerHTML = `
1 / ${this.posts.length}
正在加载图片...
`; document.body.appendChild(container); this.readerContainer = container; const searchInput = document.getElementById('series-search'); if (searchInput) { searchInput.value = this.currentSeriesName; } this.applyLayoutSizing(); this.bindReaderEvents(); this.setupResizer(); this.applySidebarState(); } bindReaderEvents() { document.getElementById('prev-floor').addEventListener('click', () => this.prevFloor()); document.getElementById('next-floor').addEventListener('click', () => this.nextFloor()); // 点击内容区域左右两侧翻页 const contentDiv = document.getElementById('reader-content'); const applyCursorZone = (zone) => { if (!contentDiv) { return; } contentDiv.classList.toggle('reader-content-hover', zone !== 'center'); contentDiv.classList.toggle('cursor-left', zone === 'left'); contentDiv.classList.toggle('cursor-right', zone === 'right'); }; contentDiv.addEventListener('click', (e) => { const rect = contentDiv.getBoundingClientRect(); const clickX = e.clientX - rect.left; const width = rect.width; const isScrollLeft = this.viewMode === CONFIG.VIEW_MODES.SCROLL_LEFT; // 左侧 30% 区域 if (clickX < width * 0.3) { if (isScrollLeft) { this.nextFloor(); } else { this.prevFloor(); } } // 右侧 30% 区域 else if (clickX > width * 0.7) { if (isScrollLeft) { this.prevFloor(); } else { this.nextFloor(); } } }); const updateHoverCursor = (event) => { const rect = contentDiv.getBoundingClientRect(); const hoverX = event.clientX - rect.left; const width = rect.width; if (hoverX < width * 0.3) { applyCursorZone('left'); } else if (hoverX > width * 0.7) { applyCursorZone('right'); } else { applyCursorZone('center'); } }; contentDiv.addEventListener('mousemove', updateHoverCursor); contentDiv.addEventListener('mouseleave', () => { applyCursorZone('center'); }); document.addEventListener('keydown', (e) => { if (!this.isReaderMode) return; if (e.key === 'ArrowLeft') { this.prevFloor(); } else if (e.key === 'ArrowRight') { this.nextFloor(); } else if (e.key === 'Escape') { this.exitReaderMode(); } }); document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', (e) => { const tab = e.target.closest('.tab-btn').dataset.tab; this.switchTab(tab); }); }); document.getElementById('favorite-btn').addEventListener('click', () => this.toggleFavorite()); document.getElementById('close-reader-top').addEventListener('click', () => this.exitReaderMode()); const toggleSidebarBtn = document.getElementById('toggle-sidebar-btn'); if (toggleSidebarBtn) { toggleSidebarBtn.addEventListener('click', () => this.toggleSidebar()); } document.getElementById('search-btn').addEventListener('click', () => this.searchDirectory()); const searchInputEl = document.getElementById('series-search'); if (searchInputEl) { searchInputEl.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); this.searchDirectory(); } }); searchInputEl.addEventListener('blur', () => { const value = searchInputEl.value.trim(); if (!value) { searchInputEl.value = this.currentSeriesName; return; } this.updateSeriesName(value, { persistOverride: true }); }); } // 暗色模式切换 document.getElementById('dark-mode-btn').addEventListener('click', () => this.toggleDarkMode()); // 阅读模式切换 document.getElementById('view-mode-btn').addEventListener('click', (e) => { const menu = document.getElementById('view-mode-menu'); const rect = e.target.getBoundingClientRect(); menu.style.top = `${rect.bottom + 5}px`; menu.style.left = `${rect.left}px`; this.syncMenuSettingControls(); menu.style.display = menu.style.display === 'none' || menu.style.display === '' ? 'block' : 'none'; }); document.querySelectorAll('#view-mode-menu .menu-item').forEach(item => { item.addEventListener('click', (e) => { const mode = e.target.closest('.menu-item').dataset.mode; this.changeViewMode(mode); document.getElementById('view-mode-menu').style.display = 'none'; }); }); const dataTransferBtn = document.getElementById('menu-data-transfer'); if (dataTransferBtn) { dataTransferBtn.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const menu = document.getElementById('view-mode-menu'); if (menu) { menu.style.display = 'none'; } this.showDataTransferDialog(); }); } // 点击其他地方关闭菜单 document.addEventListener('click', (e) => { const menu = document.getElementById('view-mode-menu'); const btn = document.getElementById('view-mode-btn'); if (menu && !menu.contains(e.target) && !btn.contains(e.target)) { menu.style.display = 'none'; } const preloadSlider = document.getElementById('menu-preload-slider'); const preloadValue = document.getElementById('menu-preload-value'); const cacheCountSpan = document.getElementById('menu-cache-count'); const clearCacheBtn = document.getElementById('menu-clear-cache'); const perPageInput = document.getElementById('menu-search-per-page'); if (preloadSlider && preloadValue && !preloadSlider.dataset.bound) { preloadSlider.dataset.bound = 'true'; preloadSlider.addEventListener('input', (event) => { const value = Number(event.target.value); preloadValue.textContent = value; this.dataStore.setSetting('preloadCount', value); }); } if (perPageInput && !perPageInput.dataset.bound) { perPageInput.dataset.bound = 'true'; perPageInput.addEventListener('change', (event) => { const normalized = normalizeSearchResultsPerPageValue(event.target.value); event.target.value = normalized; this.dataStore.setSetting('searchResultsPerPage', normalized); }); } if (clearCacheBtn && cacheCountSpan && !clearCacheBtn.dataset.bound) { clearCacheBtn.dataset.bound = 'true'; clearCacheBtn.addEventListener('click', () => { this.imageCache.clear(); cacheCountSpan.textContent = '0'; }); } }); this.updateFavoriteButton(); } applyLayoutSizing() { const container = document.getElementById('yamibo-reader-container'); if (!container) { return; } const ratio = Math.min(Math.max(this.mainWidthRatio, 0.5), 0.9); this.mainWidthRatio = ratio; const sidebarRatio = 1 - ratio; const mainWidthPercent = (ratio * 100).toFixed(3) + '%'; const sidebarWidthPercent = (sidebarRatio * 100).toFixed(3) + '%'; const contentScale = (ratio / CONFIG.DEFAULT_MAIN_WIDTH_RATIO).toFixed(3); container.style.setProperty('--main-width', mainWidthPercent); container.style.setProperty('--sidebar-width', sidebarWidthPercent); container.style.setProperty('--content-scale', contentScale); } setupResizer() { const resizer = document.getElementById('reader-resizer'); const container = document.getElementById('yamibo-reader-container'); if (!resizer || !container) { return; } const handlePointerMove = (event) => { const rect = container.getBoundingClientRect(); if (rect.width <= 0) { return; } let ratio = (event.clientX - rect.left) / rect.width; ratio = Math.min(0.9, Math.max(0.5, ratio)); this.mainWidthRatio = ratio; this.applyLayoutSizing(); }; const handlePointerUp = (event) => { if (event?.pointerId !== undefined) { resizer.releasePointerCapture?.(event.pointerId); } window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); container.classList.remove('resizing'); document.body.classList.remove('reader-resizing'); const persistedRatio = Number(this.mainWidthRatio.toFixed(3)); this.mainWidthRatio = persistedRatio; this.dataStore.setSetting('mainWidthRatio', persistedRatio); }; resizer.addEventListener('pointerdown', (event) => { event.preventDefault(); if (event.pointerId !== undefined) { resizer.setPointerCapture?.(event.pointerId); } container.classList.add('resizing'); document.body.classList.add('reader-resizing'); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); }); } toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed; this.dataStore.setSetting('sidebarCollapsed', this.sidebarCollapsed); this.applySidebarState(); } applySidebarState() { const container = this.readerContainer || document.getElementById('yamibo-reader-container'); if (!container) { return; } container.classList.toggle('sidebar-collapsed', this.sidebarCollapsed); const toggleBtn = document.getElementById('toggle-sidebar-btn'); if (toggleBtn) { toggleBtn.innerHTML = this.sidebarCollapsed ? ICONS.chevronsLeft : ICONS.chevronsRight; toggleBtn.title = this.sidebarCollapsed ? '展开右侧菜单' : '收起右侧菜单'; toggleBtn.setAttribute('aria-pressed', String(this.sidebarCollapsed)); } } isScrollMode() { return this.viewMode === CONFIG.VIEW_MODES.SCROLL_DOWN || this.viewMode === CONFIG.VIEW_MODES.SCROLL_LEFT || this.viewMode === CONFIG.VIEW_MODES.SCROLL_RIGHT; } isHorizontalScrollMode() { return this.viewMode === CONFIG.VIEW_MODES.SCROLL_LEFT || this.viewMode === CONFIG.VIEW_MODES.SCROLL_RIGHT; } isFlipMode() { return this.viewMode === CONFIG.VIEW_MODES.FLIP_LEFT_SINGLE || this.viewMode === CONFIG.VIEW_MODES.FLIP_LEFT_DOUBLE || this.viewMode === CONFIG.VIEW_MODES.FLIP_RIGHT_SINGLE || this.viewMode === CONFIG.VIEW_MODES.FLIP_RIGHT_DOUBLE; } isFlipLeftDirection() { return this.viewMode === CONFIG.VIEW_MODES.FLIP_LEFT_SINGLE || this.viewMode === CONFIG.VIEW_MODES.FLIP_LEFT_DOUBLE; } isFlipDouble() { return this.viewMode === CONFIG.VIEW_MODES.FLIP_LEFT_DOUBLE || this.viewMode === CONFIG.VIEW_MODES.FLIP_RIGHT_DOUBLE; } scrollByPage(direction) { if (!this.isHorizontalScrollMode()) { return false; } const contentDiv = document.getElementById('reader-content'); if (!contentDiv) { return false; } const containers = Array.from(contentDiv.querySelectorAll('.image-container')); if (containers.length === 0) { return false; } this.updateScrollCurrentPage(contentDiv); let targetIndex = this.currentScrollImageIndex + direction; targetIndex = Math.max(0, Math.min(containers.length - 1, targetIndex)); if (targetIndex === this.currentScrollImageIndex) { return false; } const target = containers[targetIndex]; if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); this.currentScrollImageIndex = targetIndex; this.currentImageIndex = targetIndex; this.updateFloorIndicator(); } return true; } navigateFlip(direction) { if (direction === 0 || !this.isFlipMode()) { return; } const post = this.posts[this.currentFloor]; if (!post) { return; } this.lastFlipDirection = direction > 0 ? 'next' : 'prev'; const step = this.isFlipDouble() ? 2 : 1; if (direction > 0) { if (post.images.length > 0 && this.currentImageIndex + step < post.images.length) { this.currentImageIndex += step; this.currentScrollImageIndex = this.currentImageIndex; this.renderContent(); } else if (this.currentFloor < this.posts.length - 1) { this.currentFloor++; this.currentImageIndex = 0; this.currentScrollImageIndex = 0; this.renderContent(); } } else { if (this.currentImageIndex > 0) { this.currentImageIndex = Math.max(0, this.currentImageIndex - step); this.currentScrollImageIndex = this.currentImageIndex; this.renderContent(); } else if (this.currentFloor > 0) { this.currentFloor--; const prevPost = this.posts[this.currentFloor]; if (prevPost && prevPost.images.length > 0) { const remainder = prevPost.images.length % step; if (remainder === 0) { this.currentImageIndex = Math.max(0, prevPost.images.length - step); } else { this.currentImageIndex = Math.max(0, prevPost.images.length - remainder); } } else { this.currentImageIndex = 0; } this.currentScrollImageIndex = this.currentImageIndex; this.renderContent(); } } } toggleDarkMode() { this.darkMode = !this.darkMode; this.dataStore.setSetting('darkMode', this.darkMode); document.body.classList.toggle('dark-mode', this.darkMode); const btn = document.getElementById('dark-mode-btn'); btn.innerHTML = this.darkMode ? ICONS.lightMode : ICONS.darkMode; } changeViewMode(mode) { this.viewMode = mode; this.dataStore.setSetting('viewMode', mode); this.lastFlipDirection = 'next'; // 重置图片索引 this.currentImageIndex = 0; const contentDiv = document.getElementById('reader-content'); contentDiv.setAttribute('data-view-mode', mode); this.renderContent(); } async renderContent() { const post = this.posts[this.currentFloor]; if (!post) return; const contentDiv = document.getElementById('reader-content'); if (contentDiv) { contentDiv.setAttribute('data-view-mode', this.viewMode); } if (this.isFlipMode()) { this.teardownScrollInteractions(); this.renderPageMode(contentDiv, post, this.isFlipDouble()); } else { this.renderScrollMode(contentDiv, post); } this.preloadImages(); this.updateFloorIndicator(); this.dataStore.setProgress(this.parser.threadId, this.currentFloor); // 如果已收藏,更新阅读进度 if (this.dataStore.isSeriesFavorited(this.seriesKey)) { this.dataStore.updateFavoriteChapter(this.seriesKey, { seriesTitle: this.seriesTitle, author: this.parser.authorName || '未知作者', threadId: this.parser.threadId, chapterTitle: this.parser.threadTitle, url: window.location.href, currentFloor: this.currentFloor + 1, totalFloors: this.posts.length }); } } updateFloorIndicator() { const post = this.posts[this.currentFloor]; const indicator = document.getElementById('floor-indicator'); if (this.isFlipMode()) { // 翻页模式显示图片索引 if (post && post.images.length > 0) { const step = this.isFlipDouble() ? 2 : 1; const startIndex = this.currentImageIndex; const endIndex = Math.min(startIndex + step, post.images.length); indicator.textContent = `第${this.currentFloor + 1}楼 ${startIndex + 1}-${endIndex}/${post.images.length}图`; } else { indicator.textContent = `${this.currentFloor + 1} / ${this.posts.length}`; } } else { // 滚动模式显示当前可视图片索引 if (post && post.images.length > 0) { const current = Math.min(this.currentScrollImageIndex + 1, post.images.length); indicator.textContent = `第${this.currentFloor + 1}楼 ${current}/${post.images.length}图`; } else { indicator.textContent = `${this.currentFloor + 1} / ${this.posts.length}`; } } } async renderScrollMode(contentDiv, post) { // 不清空内容,只更新图片 const existingImages = contentDiv.querySelectorAll('.image-container'); const existingCount = existingImages.length; // 如果已经有相同楼层的图片,不重新渲染 if (existingCount === post.images.length && contentDiv.dataset.floor === post.floor.toString()) { return; } contentDiv.innerHTML = ''; contentDiv.dataset.floor = post.floor; this.currentScrollImageIndex = 0; this.currentImageIndex = 0; if (post.images.length > 0) { for (let index = 0; index < post.images.length; index++) { const imgSrc = post.images[index]; const imgContainer = document.createElement('div'); imgContainer.className = 'image-container'; // 检查缓存 if (this.imageCache.has(imgSrc)) { const cachedUrl = this.imageCache.get(imgSrc); const img = document.createElement('img'); img.src = cachedUrl; img.alt = `第 ${post.floor} 楼 - 图片 ${index + 1}`; imgContainer.appendChild(img); } else { // 显示加载状态 const loader = document.createElement('div'); loader.className = 'image-loader'; loader.textContent = `加载中 ${index + 1}/${post.images.length}...`; imgContainer.appendChild(loader); // 加载并缓存图片 this.loadAndCacheImage(imgSrc, imgContainer, loader, post.floor, index + 1, post.images.length); } contentDiv.appendChild(imgContainer); } } else if (post.content) { const textContent = post.content.cloneNode(true); textContent.querySelectorAll('.pstatus, .psign, .pattm').forEach(el => el.remove()); contentDiv.appendChild(textContent); } this.setupScrollInteractions(contentDiv, post); } setupScrollInteractions(contentDiv, post) { this.teardownScrollInteractions(); if (!post || !Array.isArray(post.images) || post.images.length === 0) { this.updateFloorIndicator(); return; } const containers = contentDiv.querySelectorAll('.image-container'); containers.forEach((container, index) => { container.dataset.imageIndex = index; }); this.scrollHandler = () => { if (this.scrollUpdateScheduled) return; this.scrollUpdateScheduled = true; requestAnimationFrame(() => { this.scrollUpdateScheduled = false; this.updateScrollCurrentPage(contentDiv); }); }; contentDiv.addEventListener('scroll', this.scrollHandler, { passive: true }); this.updateScrollCurrentPage(contentDiv); } teardownScrollInteractions() { const contentDiv = document.getElementById('reader-content'); if (contentDiv && this.scrollHandler) { contentDiv.removeEventListener('scroll', this.scrollHandler); } this.scrollHandler = null; this.scrollUpdateScheduled = false; } updateScrollCurrentPage(contentDiv) { const containers = Array.from(contentDiv.querySelectorAll('.image-container')); if (containers.length === 0) { this.currentScrollImageIndex = 0; this.currentImageIndex = 0; this.updateFloorIndicator(); return; } const rect = contentDiv.getBoundingClientRect(); const referenceX = rect.left + rect.width / 2; const referenceY = rect.top + rect.height / 2; let closestIndex = 0; let minDistance = Infinity; containers.forEach((container, index) => { const containerRect = container.getBoundingClientRect(); const centerX = containerRect.left + containerRect.width / 2; const centerY = containerRect.top + containerRect.height / 2; const distance = this.viewMode === CONFIG.VIEW_MODES.SCROLL_DOWN ? Math.abs(centerY - referenceY) : Math.abs(centerX - referenceX); if (distance < minDistance) { minDistance = distance; closestIndex = index; } }); this.currentScrollImageIndex = closestIndex; this.currentImageIndex = closestIndex; this.updateFloorIndicator(); } autoOpenIfRequested() { const raw = GM_getValue(CONFIG.AUTO_OPEN_KEY, ''); if (!raw) { return; } let data = null; try { data = JSON.parse(raw); } catch (e) { console.warn('[阅读模式] 自动开启配置解析失败:', e); } GM_setValue(CONFIG.AUTO_OPEN_KEY, ''); if (!data || !data.enabled) { return; } if (data.target && data.target !== this.parser.threadId) { return; } if (data.timestamp && Date.now() - data.timestamp > 60000) { return; } if (data.seriesName && typeof data.seriesName === 'string') { this.updateSeriesName(data.seriesName, { persistOverride: true }); } setTimeout(() => { if (!this.isReaderMode) { this.enterReaderMode(); } }, 100); } async renderPageMode(contentDiv, post, isDouble) { // 翻页模式需要维护当前图片索引 if (!Number.isInteger(this.currentImageIndex) || this.currentImageIndex < 0) { this.currentImageIndex = 0; } this.currentScrollImageIndex = this.currentImageIndex; // 清空内容 contentDiv.innerHTML = ''; contentDiv.dataset.floor = post.floor; if (post.images.length > 0) { const imagesToShow = isDouble ? 2 : 1; const startIndex = this.currentImageIndex; const endIndex = Math.min(startIndex + imagesToShow, post.images.length); for (let i = startIndex; i < endIndex; i++) { const imgSrc = post.images[i]; const imgContainer = document.createElement('div'); imgContainer.className = 'image-container'; // 检查缓存 if (this.imageCache.has(imgSrc)) { const cachedUrl = this.imageCache.get(imgSrc); const img = document.createElement('img'); img.src = cachedUrl; img.alt = `第 ${post.floor} 楼 - 图片 ${i + 1}`; imgContainer.appendChild(img); } else { // 显示加载状态 const loader = document.createElement('div'); loader.className = 'image-loader'; loader.textContent = `加载中 ${i + 1}/${post.images.length}...`; imgContainer.appendChild(loader); // 加载并缓存图片 this.loadAndCacheImage(imgSrc, imgContainer, loader, post.floor, i + 1, post.images.length); } contentDiv.appendChild(imgContainer); this.applyFlipAnimation(imgContainer); } } else if (post.content) { const textContent = post.content.cloneNode(true); textContent.querySelectorAll('.pstatus, .psign, .pattm').forEach(el => el.remove()); contentDiv.appendChild(textContent); } } applyFlipAnimation(container) { if (!container || !this.isFlipMode()) { return; } const direction = this.lastFlipDirection === 'prev' ? 'prev' : 'next'; const baseClass = direction === 'prev' ? 'flip-slide-enter-prev' : 'flip-slide-enter-next'; container.classList.add('flip-slide-enter', baseClass); const cleanup = () => { container.classList.remove('flip-slide-enter', 'flip-slide-enter-next', 'flip-slide-enter-prev'); container.removeEventListener('animationend', cleanup); }; container.addEventListener('animationend', cleanup); } async loadAndCacheImage(imgSrc, container, loader, floor, index, total) { try { const cachedUrl = await this.imageCache.load(imgSrc); const img = document.createElement('img'); img.src = cachedUrl; img.alt = `第 ${floor} 楼 - 图片 ${index}`; img.onload = () => { if (loader && loader.parentNode) { loader.remove(); } container.appendChild(img); }; img.onerror = () => { if (loader) { loader.textContent = '加载失败'; loader.classList.add('error'); } }; } catch (e) { console.error('Failed to load image:', imgSrc, e); if (loader) { loader.textContent = '加载失败'; loader.classList.add('error'); } } } preloadImages() { // 获取预加载数量配置(按图片计数) const preloadCount = this.dataStore.getSetting('preloadCount', CONFIG.PRELOAD_COUNT); if (preloadCount === 0) { return; } let scheduledCount = 0; const totalPosts = this.posts.length; // 从当前楼层的下一张图片开始,逐张预加载 for (let floorIndex = this.currentFloor; floorIndex < totalPosts && scheduledCount < preloadCount; floorIndex++) { const post = this.posts[floorIndex]; if (!post || !Array.isArray(post.images) || post.images.length === 0) { continue; } const startImageIndex = floorIndex === this.currentFloor ? (this.currentImageIndex || 0) + 1 : 0; if (startImageIndex >= post.images.length) { continue; } for (let imgIndex = startImageIndex; imgIndex < post.images.length && scheduledCount < preloadCount; imgIndex++) { const imgSrc = post.images[imgIndex]; if (!imgSrc) { continue; } if (this.imageCache.has(imgSrc)) { continue; } this.imageCache.load(imgSrc).then(() => { }).catch(e => { console.error(`[预加载] ✗ 失败: 第${floorIndex + 1}楼-第${imgIndex + 1}张`, e); }); scheduledCount++; } } } prevFloor() { if (this.isHorizontalScrollMode() && this.scrollByPage(-1)) { return; } if (this.isFlipMode()) { const stepDirection = this.isFlipLeftDirection() ? 1 : -1; this.navigateFlip(stepDirection); return; } if (this.currentFloor > 0) { this.currentFloor--; this.currentImageIndex = 0; this.currentScrollImageIndex = 0; this.renderContent(); } } nextFloor() { if (this.isHorizontalScrollMode() && this.scrollByPage(1)) { return; } if (this.isFlipMode()) { const stepDirection = this.isFlipLeftDirection() ? -1 : 1; this.navigateFlip(stepDirection); return; } if (this.currentFloor < this.posts.length - 1) { this.currentFloor++; this.currentImageIndex = 0; this.currentScrollImageIndex = 0; this.renderContent(); } } goToFloor(targetFloor, targetImageIndex = 0) { if (!this.isReaderMode) { this.enterReaderMode(); } if (!Array.isArray(this.posts) || this.posts.length === 0) { return; } const clampedFloor = Math.max(0, Math.min(this.posts.length - 1, targetFloor)); const clampedImageIndex = Math.max(0, targetImageIndex); this.currentFloor = clampedFloor; this.currentImageIndex = clampedImageIndex; this.currentScrollImageIndex = clampedImageIndex; this.lastFlipDirection = 'next'; this.renderContent(); const contentDiv = document.getElementById('reader-content'); if (contentDiv) { contentDiv.scrollTop = 0; contentDiv.scrollLeft = 0; } } switchTab(tabName) { document.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tabName); }); document.querySelectorAll('.tab-panel').forEach(panel => { panel.classList.toggle('active', panel.id === `${tabName}-panel`); }); if (tabName === 'comments') { this.loadComments(); } else if (tabName === 'favorites') { this.loadFavorites(); } else if (tabName === 'directory') { this.scrollDirectoryToCurrent(); } } updateSeriesName(rawName, options = {}) { const { persistOverride = true } = options; const trimmed = typeof rawName === 'string' ? rawName.trim() : ''; if (!trimmed) { return false; } const previousKey = this.seriesKey; const previousName = this.currentSeriesName; this.currentSeriesName = trimmed; this.seriesTitle = trimmed; const newKey = buildSeriesKey(trimmed) || previousKey; this.seriesKey = newKey; if (persistOverride) { this.dataStore.setSeriesNameOverride(this.baseSeriesKey, trimmed); } if (previousKey && previousKey !== newKey) { this.dataStore.renameFavoriteSeries(previousKey, newKey, trimmed); } else if (previousKey === newKey && previousName !== trimmed) { const existingFavorite = this.dataStore.getFavorite(newKey); if (existingFavorite) { existingFavorite.seriesTitle = trimmed; this.dataStore.save(); } } const searchInput = document.getElementById('series-search'); if (searchInput && searchInput.value.trim() !== trimmed) { searchInput.value = trimmed; } this.updateFavoriteButton(); this.updateFavoriteDirectoryCountDisplay(); const favPanel = document.getElementById('favorites-panel'); if (favPanel && favPanel.classList.contains('active')) { this.loadFavorites(); } return previousKey !== newKey || previousName !== trimmed; } toggleFavorite() { const isFavorited = this.dataStore.isSeriesFavorited(this.seriesKey); if (isFavorited) { this.dataStore.removeFavorite(this.seriesKey); } else { this.dataStore.addOrUpdateFavorite(this.seriesKey, { seriesTitle: this.seriesTitle, author: this.parser.authorName || '未知作者', threadId: this.parser.threadId, chapterTitle: this.parser.threadTitle, url: window.location.href, currentFloor: this.currentFloor + 1, totalFloors: this.posts.length, directoryCount: typeof this.currentDirectoryCount === 'number' && this.currentDirectoryCount >= 0 ? Math.floor(this.currentDirectoryCount) : this.dataStore.getSeriesDirectoryCount(this.seriesKey) }); } this.updateFavoriteButton(); // 刷新收藏列表(如果正在显示) const favPanel = document.getElementById('favorites-panel'); if (favPanel && favPanel.classList.contains('active')) { this.loadFavorites(); } } updateFavoriteButton() { const btn = document.getElementById('favorite-btn'); if (btn) { const isFavorited = this.dataStore.isSeriesFavorited(this.seriesKey); btn.innerHTML = isFavorited ? ICONS.bookmarkFilled : ICONS.bookmark; btn.classList.toggle('favorited', isFavorited); btn.title = isFavorited ? '取消收藏' : '收藏'; } } showDataTransferDialog() { const existingOverlay = document.getElementById('data-transfer-overlay'); if (existingOverlay) { existingOverlay.remove(); } const overlay = document.createElement('div'); overlay.id = 'data-transfer-overlay'; overlay.className = 'data-transfer-overlay'; overlay.innerHTML = `

数据导入 / 导出

包含收藏、阅读设置与阅读进度。打开时会展示当前保存的数据。
`; document.body.appendChild(overlay); const dialog = overlay.querySelector('.data-transfer-dialog'); const textarea = overlay.querySelector('#data-transfer-textarea'); const saveBtn = overlay.querySelector('#data-transfer-save'); const cancelBtn = overlay.querySelector('#data-transfer-cancel'); const closeBtn = overlay.querySelector('.data-transfer-close'); let handleKeydown = null; const closeDialog = () => { if (handleKeydown) { document.removeEventListener('keydown', handleKeydown); } if (overlay && overlay.parentNode) { overlay.remove(); } }; handleKeydown = (event) => { if (event.key === 'Escape') { closeDialog(); } }; document.addEventListener('keydown', handleKeydown); if (textarea) { textarea.value = this.dataStore.exportData(true); textarea.focus(); textarea.select(); } if (saveBtn && textarea) { saveBtn.addEventListener('click', () => { const raw = textarea.value.trim(); if (!raw) { alert('请先填写 JSON 数据'); textarea.focus(); return; } try { this.dataStore.importData(raw); this.applySettingsFromStore(); textarea.value = this.dataStore.exportData(true); alert('数据已保存!'); } catch (err) { console.error('导入失败', err); alert(`导入失败: ${err.message || err}`); } }); } const registerClose = (element) => { if (!element) { return; } element.addEventListener('click', () => { closeDialog(); }); }; registerClose(cancelBtn); registerClose(closeBtn); overlay.addEventListener('click', (event) => { if (event.target === overlay) { closeDialog(); } }); if (dialog) { dialog.addEventListener('click', (event) => { event.stopPropagation(); }); } } applySettingsFromStore() { const storedPreload = parseInt(this.dataStore.getSetting('preloadCount', CONFIG.PRELOAD_COUNT), 10); if (Number.isFinite(storedPreload)) { CONFIG.PRELOAD_COUNT = storedPreload; } this.darkMode = this.dataStore.getSetting('darkMode', false); document.body.classList.toggle('dark-mode', this.darkMode); const darkModeBtn = document.getElementById('dark-mode-btn'); if (darkModeBtn) { darkModeBtn.innerHTML = this.darkMode ? ICONS.lightMode : ICONS.darkMode; } let importedViewMode = this.dataStore.getSetting('viewMode', CONFIG.VIEW_MODES.SCROLL_DOWN); importedViewMode = LEGACY_VIEW_MODE_MAP[importedViewMode] || importedViewMode; if (!ALL_VIEW_MODES.has(importedViewMode)) { importedViewMode = CONFIG.VIEW_MODES.SCROLL_DOWN; } this.viewMode = importedViewMode; const ratioSetting = Number(this.dataStore.getSetting('mainWidthRatio', this.mainWidthRatio)); if (Number.isFinite(ratioSetting)) { this.mainWidthRatio = Math.min(Math.max(ratioSetting, 0.5), 0.9); this.applyLayoutSizing(); } this.applyFloatingButtonPosition(); this.updateFavoriteButton(); this.syncMenuSettingControls(); this.sidebarCollapsed = !!this.dataStore.getSetting('sidebarCollapsed', this.sidebarCollapsed); this.applySidebarState(); if (this.isReaderMode) { const contentDiv = document.getElementById('reader-content'); if (contentDiv) { contentDiv.setAttribute('data-view-mode', this.viewMode); } this.renderContent(); this.scrollDirectoryToCurrent(); const favPanel = document.getElementById('favorites-panel'); if (favPanel && favPanel.classList.contains('active')) { this.loadFavorites(); } } } syncMenuSettingControls() { const slider = document.getElementById('menu-preload-slider'); const sliderValue = document.getElementById('menu-preload-value'); const cacheCountSpan = document.getElementById('menu-cache-count'); const perPageInput = document.getElementById('menu-search-per-page'); const currentPreload = this.dataStore.getSetting('preloadCount', CONFIG.PRELOAD_COUNT); if (slider) { slider.value = currentPreload; } if (sliderValue) { sliderValue.textContent = currentPreload; } if (cacheCountSpan) { cacheCountSpan.textContent = this.imageCache.cache.size; } if (perPageInput) { perPageInput.value = this.getSearchResultsPerPage(); } } getViewModeName(mode) { const names = { [CONFIG.VIEW_MODES.SCROLL_DOWN]: '滑动-下', [CONFIG.VIEW_MODES.SCROLL_LEFT]: '滑动-左', [CONFIG.VIEW_MODES.SCROLL_RIGHT]: '滑动-右', [CONFIG.VIEW_MODES.FLIP_LEFT_SINGLE]: '翻页-左-单页', [CONFIG.VIEW_MODES.FLIP_LEFT_DOUBLE]: '翻页-左-双页', [CONFIG.VIEW_MODES.FLIP_RIGHT_SINGLE]: '翻页-右-单页', [CONFIG.VIEW_MODES.FLIP_RIGHT_DOUBLE]: '翻页-右-双页' }; return names[mode] || mode; } extractThreadIdFromUrl(url) { if (!url) return null; let match = url.match(/thread-(\d+)-/); if (match) { return match[1]; } match = url.match(/[?&]tid=(\d+)/); return match ? match[1] : null; } handleDirectoryNavigation(event, anchor) { event.preventDefault(); const url = anchor.href; const targetThreadId = anchor.dataset.threadId || this.extractThreadIdFromUrl(url); if (!url) { return; } if (!targetThreadId) { window.location.href = url; return; } if (targetThreadId === this.parser.threadId) { return; } this.scheduleAutoOpen(targetThreadId); window.location.href = url; } scheduleAutoOpen(targetThreadId) { const payload = { enabled: true, target: targetThreadId, timestamp: Date.now(), seriesName: this.currentSeriesName }; GM_setValue(CONFIG.AUTO_OPEN_KEY, JSON.stringify(payload)); } loadDirectory() { this.searchDirectory(); } getSearchResultsPerPage() { const storedValue = this.dataStore.getSetting('searchResultsPerPage', CONFIG.SEARCH_RESULTS_PER_PAGE_DEFAULT); const normalized = normalizeSearchResultsPerPageValue(storedValue); if (normalized !== storedValue) { this.dataStore.setSetting('searchResultsPerPage', normalized); } return normalized; } searchDirectory() { console.log('[搜索] ===== 开始搜索合集 ====='); const searchInput = document.getElementById('series-search'); const query = searchInput ? searchInput.value.trim() : ''; console.log('[搜索] 搜索关键词:', query); if (!query) { console.log('[搜索] 错误: 搜索关键词为空'); this.showDirectoryError('请输入搜索关键词'); return; } this.updateSeriesName(query, { persistOverride: true }); const perPageSetting = this.getSearchResultsPerPage(); this.setDirectoryLoadingMessage(`搜索中... (每页${perPageSetting}条)`); // Discuz 搜索需要先GET获取formhash console.log('[搜索] 第一步: 获取搜索页面formhash'); const searchPageUrl = 'https://bbs.yamibo.com/search.php?mod=forum'; GM_xmlhttpRequest({ method: 'GET', url: searchPageUrl, onload: (response) => { console.log('[搜索] 获取搜索页面成功,状态码:', response.status); // 提取formhash const doc = new DOMParser().parseFromString(response.responseText, 'text/html'); const formhashInput = doc.querySelector('input[name="formhash"]'); const formhash = formhashInput ? formhashInput.value : ''; console.log('[搜索] 提取到formhash:', formhash); // 直接使用GET方式搜索(更可靠) const finalSearchUrl = `https://bbs.yamibo.com/search.php?mod=forum&srchtxt=${encodeURIComponent(query)}&formhash=${formhash}&searchsubmit=yes`; console.log('[搜索] 第二步: 执行搜索请求'); console.log('[搜索] 请求URL:', finalSearchUrl); GM_xmlhttpRequest({ method: 'GET', url: finalSearchUrl, onload: (searchResponse) => { console.log('[搜索] 搜索请求完成! 状态码:', searchResponse.status); console.log('[搜索] 最终URL:', searchResponse.finalUrl); console.log('[搜索] 响应内容长度:', searchResponse.responseText.length); // 检查是否是错误页面 if (searchResponse.responseText.includes('System Error') || searchResponse.responseText.includes('搜索无结果')) { console.log('[搜索] 错误: 搜索返回错误页面或无结果'); this.showDirectoryError('搜索失败,请稍后重试'); return; } this.handleSearchResponse(searchResponse, perPageSetting); }, onerror: (error) => { console.error('[搜索] 搜索请求失败:', error); this.showDirectoryError('搜索失败,10秒后自动重试'); this.scheduleRetry(); } }); }, onerror: (error) => { console.error('[搜索] 获取搜索页面失败:', error); this.showDirectoryError('搜索失败,10秒后自动重试'); this.scheduleRetry(); } }); } handleSearchResponse(searchResponse, perPageSetting) { const aggregatedEntries = []; const seenKeys = new Set(); const normalizedUrl = this.buildSearchPageUrl(searchResponse?.finalUrl, 1, perPageSetting); if (normalizedUrl) { this.fetchFirstPageAndContinue({ url: normalizedUrl, perPage: perPageSetting, aggregatedEntries, seenKeys }); return; } console.log('[搜索] 未能构造分页URL,直接使用初始响应渲染'); this.processSearchPageHtml(searchResponse.responseText, normalizedUrl, perPageSetting, aggregatedEntries, seenKeys); } fetchFirstPageAndContinue({ url, perPage, aggregatedEntries, seenKeys }) { this.setDirectoryLoadingMessage('搜索中... (第1页)'); GM_xmlhttpRequest({ method: 'GET', url, onload: (resp) => { console.log('[搜索] 第 1 页重新获取成功,状态码:', resp.status); this.processSearchPageHtml(resp.responseText, url, perPage, aggregatedEntries, seenKeys); }, onerror: (error) => { console.error('[搜索] 重新获取第 1 页失败:', error); this.showDirectoryError('搜索失败,请稍后重试'); } }); } processSearchPageHtml(html, baseUrl, perPage, aggregatedEntries, seenKeys) { const firstPageEntries = this.extractSearchEntriesFromHtml(html); const firstPageCount = this.appendDirectoryEntries(firstPageEntries, aggregatedEntries, seenKeys); console.log(`[搜索] 第 1 页解析完成,条目数: ${firstPageCount}`); if (!baseUrl) { console.log('[搜索] 未能构造有效的分页URL,直接渲染当前结果'); this.renderDirectoryEntries(aggregatedEntries); return; } if (firstPageCount < perPage) { this.renderDirectoryEntries(aggregatedEntries); return; } this.fetchAdditionalSearchPages({ baseUrl, nextPage: 2, perPage, aggregatedEntries, seenKeys }); } fetchAdditionalSearchPages({ baseUrl, nextPage, perPage, aggregatedEntries, seenKeys }) { if (nextPage > CONFIG.MAX_SEARCH_PAGES) { console.log(`[搜索] 已达到最大翻页数量 ${CONFIG.MAX_SEARCH_PAGES},停止继续请求`); this.renderDirectoryEntries(aggregatedEntries); return; } const nextUrl = this.buildSearchPageUrl(baseUrl, nextPage, perPage); if (!nextUrl) { console.log('[搜索] 无法构造下一页URL,停止继续请求'); this.renderDirectoryEntries(aggregatedEntries); return; } this.setDirectoryLoadingMessage(`搜索中... (第${nextPage}页)`); GM_xmlhttpRequest({ method: 'GET', url: nextUrl, onload: (resp) => { console.log(`[搜索] 第 ${nextPage} 页请求完成,状态码:`, resp.status); const pageEntries = this.extractSearchEntriesFromHtml(resp.responseText); const pageCount = this.appendDirectoryEntries(pageEntries, aggregatedEntries, seenKeys); if (pageCount < perPage) { this.renderDirectoryEntries(aggregatedEntries); } else { this.fetchAdditionalSearchPages({ baseUrl, nextPage: nextPage + 1, perPage, aggregatedEntries, seenKeys }); } }, onerror: (error) => { console.error(`[搜索] 第 ${nextPage} 页请求失败:`, error); this.renderDirectoryEntries(aggregatedEntries); } }); } buildSearchPageUrl(baseUrl, pageNumber, perPage) { if (!baseUrl) { return ''; } let urlObj; try { urlObj = new URL(baseUrl); } catch (err) { try { urlObj = new URL(baseUrl, 'https://bbs.yamibo.com/'); } catch (error) { console.error('[搜索] 无法解析搜索URL:', baseUrl, error); return ''; } } if (pageNumber !== undefined && pageNumber !== null) { urlObj.searchParams.set('page', String(pageNumber)); } if (perPage) { urlObj.searchParams.set('perpage', String(perPage)); } return urlObj.toString(); } appendDirectoryEntries(entries, aggregatedEntries, seenKeys) { if (!Array.isArray(entries) || entries.length === 0) { return 0; } entries.forEach(entry => { const key = entry.threadId ? `tid:${entry.threadId}` : (entry.url ? `url:${entry.url}` : ''); if (key && seenKeys.has(key)) { return; } if (key) { seenKeys.add(key); } aggregatedEntries.push(entry); }); return entries.length; } extractSearchEntriesFromHtml(html) { console.log('[解析] ===== 开始解析搜索结果 ====='); const entries = []; if (!html) { return entries; } const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const selectors = [ 'tbody[id^="normalthread_"]', '#threadlist tbody', 'table.tbopt tbody[id^="normalthread_"]', '.tl tbody', '.pbw .xl.xl2.o.cl li', '.pbw .xl.xl2 li', '.threadlist li', '#threadlist li', 'li.pbw' ]; let results = []; let usedSelector = ''; for (const selector of selectors) { results = doc.querySelectorAll(selector); if (results.length > 0) { usedSelector = selector; console.log(`[解析] 使用选择器 "${selector}" 找到 ${results.length} 个结果`); break; } } if (results.length === 0) { results = doc.querySelectorAll('a.s.xst, a.xst'); usedSelector = 'a.s.xst, a.xst'; console.log(`[解析] 使用兜底选择器 "${usedSelector}" 找到 ${results.length} 个链接`); } const linkSelector = 'a.s.xst, a.xst, a[href*="thread-"], a[href*="viewthread"]'; results.forEach((item, index) => { let link = null; if (item.tagName === 'TBODY' || item.tagName === 'LI') { link = item.querySelector(linkSelector); } else if (item.tagName === 'A') { link = item; } if (!link) { return; } const title = link.textContent.trim(); const href = link.getAttribute('href'); const isThreadLink = !!href && (/thread-\d+-/.test(href) || href.includes('mod=viewthread')); if (!href || !title || !isThreadLink) { console.log(`[解析] 跳过第 ${index + 1} 个结果: href=${href}, title=${title}`); return; } const sanitizedHref = href.replace(/^\/+/, ''); const fullUrl = href.startsWith('http') ? href : `https://bbs.yamibo.com/${sanitizedHref}`; const threadId = this.extractThreadIdFromUrl(fullUrl); entries.push({ title, url: fullUrl, threadId }); }); console.log(`[解析] 原始页面共解析到 ${entries.length} 个项目`); return entries; } renderDirectoryEntries(entries) { const listDiv = document.getElementById('directory-list'); if (!listDiv) { return; } if (!entries || entries.length === 0) { console.log('[解析] 未找到任何有效搜索结果'); this.showDirectoryError('未找到相关内容'); return; } listDiv.innerHTML = ''; let foundCount = 0; entries.forEach(entry => { const itemDiv = document.createElement('div'); itemDiv.className = 'directory-item'; if (entry.threadId && entry.threadId === this.parser.threadId) { itemDiv.classList.add('current'); } const anchor = document.createElement('a'); anchor.href = entry.url; anchor.textContent = entry.title; if (entry.threadId) { anchor.dataset.threadId = entry.threadId; } anchor.addEventListener('click', (event) => this.handleDirectoryNavigation(event, anchor)); itemDiv.appendChild(anchor); listDiv.appendChild(itemDiv); foundCount++; }); console.log(`[解析] 解析完成,共找到 ${foundCount} 个有效结果`); this.currentDirectoryCount = foundCount; this.updateFavoriteDirectoryCountDisplay(); if (this.dataStore.isSeriesFavorited(this.seriesKey)) { this.dataStore.updateSeriesDirectoryCount(this.seriesKey, foundCount); } requestAnimationFrame(() => this.scrollDirectoryToCurrent()); } setDirectoryLoadingMessage(text) { const listDiv = document.getElementById('directory-list'); if (listDiv) { listDiv.innerHTML = `
${text}
`; } this.currentDirectoryCount = null; this.updateFavoriteDirectoryCountDisplay(); } scrollDirectoryToCurrent() { const listDiv = document.getElementById('directory-list'); if (!listDiv) { return; } const currentItem = listDiv.querySelector('.directory-item.current'); if (!currentItem) { return; } const targetScroll = currentItem.offsetTop - Math.max(0, (listDiv.clientHeight - currentItem.offsetHeight) / 2); listDiv.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' }); } showDirectoryError(message) { const listDiv = document.getElementById('directory-list'); listDiv.innerHTML = `
${message}
`; this.currentDirectoryCount = null; this.updateFavoriteDirectoryCountDisplay(); } scheduleRetry() { if (this.searchRetryTimer) { clearTimeout(this.searchRetryTimer); } this.searchRetryTimer = setTimeout(() => { this.searchDirectory(); }, CONFIG.SEARCH_RETRY_DELAY); } loadComments() { const commentsDiv = document.getElementById('comments-list'); const allPosts = document.querySelectorAll('#postlist > div[id^="post_"]'); const comments = []; allPosts.forEach((postEl) => { const authorLink = postEl.querySelector('.favatar .authi a'); if (authorLink) { const href = authorLink.getAttribute('href'); let match = href.match(/uid=(\d+)/); if (!match) { match = href.match(/uid-(\d+)/); } if (match && match[1] !== this.parser.authorUid) { const author = authorLink.textContent.trim(); const content = postEl.querySelector('.t_f, .pcb'); const floorNum = this.parser.getFloorNumber(postEl); const timeEl = postEl.querySelector('.pti .authi em'); comments.push({ floor: floorNum, author, content: content ? content.innerHTML : '', time: timeEl ? timeEl.textContent : '' }); } } }); if (comments.length === 0) { commentsDiv.innerHTML = '
暂无评论
'; return; } commentsDiv.innerHTML = ''; comments.forEach(comment => { const commentDiv = document.createElement('div'); commentDiv.className = 'comment-item'; commentDiv.innerHTML = `
${comment.author} #${comment.floor} ${comment.time}
${comment.content}
`; commentsDiv.appendChild(commentDiv); }); } updateFavoriteDirectoryCountDisplay() { const selector = `.favorite-count[data-series-key="${this.seriesKey}"]`; const stored = this.dataStore.getSeriesDirectoryCount ? this.dataStore.getSeriesDirectoryCount(this.seriesKey) : 0; const normalizedStored = Number.isFinite(stored) && stored >= 0 ? Math.floor(stored) : 0; const hasCurrent = typeof this.currentDirectoryCount === 'number' && this.currentDirectoryCount >= 0; const normalizedCurrent = hasCurrent ? Math.floor(this.currentDirectoryCount) : null; const displayCount = normalizedCurrent !== null ? normalizedCurrent : normalizedStored; document.querySelectorAll(selector).forEach(span => { span.textContent = `收录章节: ${displayCount} 篇`; }); } loadFavorites() { const favoritesDiv = document.getElementById('favorites-list'); const favorites = this.dataStore.getAllFavorites(); favoritesDiv.innerHTML = ''; if (favorites.length === 0) { favoritesDiv.innerHTML = '
暂无收藏
'; return; } // 按最后访问时间排序 favorites.sort((a, b) => (b.lastVisited || 0) - (a.lastVisited || 0)); favorites.forEach(fav => { const favItem = document.createElement('div'); favItem.className = 'favorite-item'; const isCurrentSeries = fav.seriesKey === this.seriesKey; if (isCurrentSeries) { favItem.classList.add('current'); } const seriesTitle = fav.seriesTitle || '未命名合集'; const latestTitle = fav.latestTitle || '未记录章节'; const latestUrl = fav.latestUrl || ''; const latestThreadId = fav.latestThreadId || ''; const latestFloor = fav.latestFloor || 0; const latestTotalFloors = fav.latestTotalFloors || 0; const storedChapterCount = fav.chapters ? Object.keys(fav.chapters).length : 0; const storedDirectoryCountRaw = Number(fav.directoryCount); const normalizedStoredDirectoryCount = Number.isFinite(storedDirectoryCountRaw) && storedDirectoryCountRaw >= 0 ? Math.floor(storedDirectoryCountRaw) : Math.max(0, storedChapterCount); const hasCurrentDirectoryCount = fav.seriesKey === this.seriesKey && typeof this.currentDirectoryCount === 'number' && this.currentDirectoryCount >= 0; const currentSeriesDirectoryCount = hasCurrentDirectoryCount ? Math.floor(this.currentDirectoryCount) : null; const directoryCount = hasCurrentDirectoryCount ? currentSeriesDirectoryCount : normalizedStoredDirectoryCount; let timeStr = '未访问'; if (fav.lastVisited) { const date = new Date(fav.lastVisited); const now = new Date(); const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); if (diffDays === 0) { timeStr = '今天'; } else if (diffDays === 1) { timeStr = '昨天'; } else if (diffDays < 7) { timeStr = `${diffDays}天前`; } else { timeStr = date.toLocaleDateString(); } } let progressText = '未开始'; if (latestFloor > 0 && latestTotalFloors > 0) { progressText = `第${latestFloor}/${latestTotalFloors}楼`; } else if (latestFloor > 0) { progressText = `第${latestFloor}楼`; } const authorText = fav.author ? `作者: ${fav.author}` : ''; const displayDirectoryCount = Number.isFinite(directoryCount) && directoryCount >= 0 ? directoryCount : 0; const chapterCountText = `收录章节: ${displayDirectoryCount} 篇`; const continueDisabled = !latestUrl; const continueTitle = continueDisabled ? '暂无可继续的章节' : '继续阅读'; const seriesTitleHtml = continueDisabled ? `${seriesTitle}` : `${seriesTitle}`; const latestLink = latestUrl ? `${latestTitle}` : `${latestTitle}`; favItem.innerHTML = `
${seriesTitleHtml} ${authorText}
${chapterCountText}
上次阅读: ${latestLink}
进度: ${progressText}
最近阅读: ${timeStr}
`; favoritesDiv.appendChild(favItem); }); favoritesDiv.querySelectorAll('.favorite-action-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const action = btn.dataset.action; const seriesKey = btn.dataset.seriesKey; const threadId = btn.dataset.threadId; const floor = parseInt(btn.dataset.floor || '0', 10) || 0; const targetUrl = btn.dataset.url; if (action === 'continue') { const fav = favorites.find(f => f.seriesKey === seriesKey); if (fav) { if (threadId && threadId === this.parser.threadId) { if (floor > 0) { this.goToFloor(Math.max(0, floor - 1)); } } else if (targetUrl) { window.open(targetUrl, '_blank'); } } } else if (action === 'remove') { if (confirm('确定要取消收藏吗?')) { this.dataStore.removeFavorite(seriesKey); this.loadFavorites(); if (seriesKey === this.seriesKey) { this.updateFavoriteButton(); } } } }); }); this.updateFavoriteDirectoryCountDisplay(); } } // ========================= // Material Design 样式 // ========================= GM_addStyle(` :root { --primary-color: #4caf50; --primary-dark: #388e3c; --primary-light: #81c784; --accent-color: #66bb6a; --background: #fafafa; --surface: #ffffff; --error: #f44336; --text-primary: rgba(0,0,0,0.87); --text-secondary: rgba(0,0,0,0.6); --divider: rgba(0,0,0,0.12); --shadow-1: 0 2px 4px rgba(0,0,0,0.1); --shadow-2: 0 4px 8px rgba(0,0,0,0.12); --shadow-3: 0 8px 16px rgba(0,0,0,0.15); } body.dark-mode { --primary-color: #66bb6a; --primary-dark: #4caf50; --primary-light: #81c784; --accent-color: #81c784; --background: #121212; --surface: #1e1e1e; --error: #cf6679; --text-primary: rgba(255,255,255,0.87); --text-secondary: rgba(255,255,255,0.6); --divider: rgba(255,255,255,0.12); } #yamibo-reader-btn { position: fixed; right: 20px; top: 50%; transform: translateY(-50%); width: 56px; height: 56px; background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: var(--shadow-3); z-index: 99999; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); touch-action: none; } #yamibo-reader-btn svg { width: 28px; height: 28px; } #yamibo-reader-btn:hover { transform: translateY(-50%) scale(1.1); box-shadow: 0 12px 24px rgba(76,175,80,0.3); } #yamibo-reader-btn.edge-left, #yamibo-reader-btn.edge-right { opacity: 0.4; } #yamibo-reader-btn.edge-left { clip-path: inset(0 0 0 50%); } #yamibo-reader-btn.edge-right { clip-path: inset(0 50% 0 0); } #yamibo-reader-btn.edge-left.edge-expanded, #yamibo-reader-btn.edge-right.edge-expanded { clip-path: none; opacity: 1; } body.yamibo-reader-active > *:not(#yamibo-reader-container):not(#yamibo-reader-btn):not(#data-transfer-overlay) { display: none !important; } #yamibo-reader-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: var(--background); color: var(--text-primary); z-index: 99998; display: flex; --main-width: 70%; --sidebar-width: 30%; --content-scale: 1.133; --toolbar-height: 44px; } .reader-main { flex: 0 0 var(--main-width); width: var(--main-width); height: 100%; display: flex; flex-direction: column; background: var(--surface); overflow: hidden; } .reader-main-inner { width: 100%; height: 100%; display: flex; flex-direction: column; } .reader-content-wrapper { flex: 1; display: flex; position: relative; overflow: hidden; min-width: 0; min-height: 0; } .reader-content-wrapper .reader-content { flex: 1; width: calc(100% / var(--content-scale)); height: calc(100% / var(--content-scale)); transform: scale(var(--content-scale)); transform-origin: left top; min-width: 0; min-height: 0; } .reader-resizer { flex: 0 0 6px; width: 6px; background: var(--divider); cursor: col-resize; position: relative; z-index: 12; transition: background 0.2s ease; touch-action: none; } .reader-resizer::after { content: ''; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 2px; height: 40px; background: var(--text-secondary); border-radius: 1px; opacity: 0.6; } #yamibo-reader-container.resizing .reader-resizer, .reader-resizer:hover { background: var(--primary-light); } body.reader-resizing { user-select: none; cursor: col-resize; } body.reader-btn-dragging { cursor: move !important; } .reader-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--surface); border-bottom: 1px solid var(--divider); box-shadow: var(--shadow-1); min-height: var(--toolbar-height); } .toolbar-left, .toolbar-right { display: flex; gap: 6px; align-items: center; } .toolbar-center { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; } .icon-btn { width: 32px; height: 32px; border: none; background: var(--surface); color: var(--text-primary); border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.2s, box-shadow 0.2s; box-shadow: var(--shadow-1); } .icon-btn:hover { background: var(--divider); box-shadow: var(--shadow-2); } .icon-btn svg { width: 18px; height: 18px; } .icon-btn.favorited { color: var(--primary-color); } .reader-content { flex: none; overflow-y: auto; overflow-x: hidden; padding: 20px; display: flex; gap: 20px; } .reader-content[data-view-mode="scroll-down"] { flex-direction: column; align-items: center; } .reader-content[data-view-mode="scroll-right"] { flex-direction: row; align-items: flex-start; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scroll-behavior: smooth; } .reader-content.reader-content-hover { cursor: pointer; } .reader-content.cursor-left { cursor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc2NCcgaGVpZ2h0PSc2NCcgdmlld0JveD0nMCAwIDI0IDI0JyBmaWxsPSdub25lJz48cGF0aCBmaWxsPScjNGNhZjUwJyBmaWxsLW9wYWNpdHk9JzAuOCcgZD0nTTE1LjQxIDcuNDFMMTQgNmwtNiA2IDYgNiAxLjQxLTEuNDFMMTAuODMgMTJ6Jy8+PC9zdmc+') 16 32, pointer; } .reader-content.cursor-right { cursor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc2NCcgaGVpZ2h0PSc2NCcgdmlld0JveD0nMCAwIDI0IDI0JyBmaWxsPSdub25lJz48cGF0aCBmaWxsPScjNGNhZjUwJyBmaWxsLW9wYWNpdHk9JzAuOCcgZD0nTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eicvPjwvc3ZnPg==') 48 32, pointer; } .image-container.flip-slide-enter { will-change: opacity, filter; animation-duration: 0.32s; animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); animation-fill-mode: both; } .image-container.flip-slide-enter-next { animation-name: flip-slide-next; } .image-container.flip-slide-enter-prev { animation-name: flip-slide-prev; } @keyframes flip-slide-next { 0% { opacity: 0; filter: brightness(0.75); } 60% { opacity: 1; filter: brightness(0.92); } 100% { opacity: 1; filter: brightness(1); } } @keyframes flip-slide-prev { 0% { opacity: 0; filter: brightness(0.75); } 60% { opacity: 1; filter: brightness(0.92); } 100% { opacity: 1; filter: brightness(1); } } @media (prefers-reduced-motion: reduce) { .image-container.flip-slide-enter { animation: none !important; } } .reader-content[data-view-mode="scroll-left"] { flex-direction: row-reverse; align-items: flex-start; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scroll-behavior: smooth; } .reader-content[data-view-mode="flip-left-single"], .reader-content[data-view-mode="flip-right-single"] { flex-direction: column; align-items: center; justify-content: center; padding: 12px 0; } .reader-content[data-view-mode="flip-left-double"], .reader-content[data-view-mode="flip-right-double"] { flex-direction: row; align-items: center; justify-content: center; padding: 12px 16px; gap: 12px; } .reader-content[data-view-mode="flip-left-double"] { flex-direction: row-reverse; } .image-container { position: relative; display: flex; justify-content: center; align-items: center; } .reader-content[data-view-mode="scroll-down"] .image-container, .reader-content[data-view-mode="flip-left-single"] .image-container, .reader-content[data-view-mode="flip-right-single"] .image-container { width: 100%; } .reader-content[data-view-mode="scroll-right"] .image-container, .reader-content[data-view-mode="scroll-left"] .image-container { flex-shrink: 0; height: calc(100vh - 120px); scroll-snap-align: center; margin: 0 6px; } .reader-content[data-view-mode="flip-left-double"] .image-container, .reader-content[data-view-mode="flip-right-double"] .image-container { flex: 1; max-width: 49%; height: calc(100vh - 120px); } .image-container img { max-width: 100%; height: auto; box-shadow: var(--shadow-2); border-radius: 8px; } .reader-content[data-view-mode="flip-left-single"] .image-container img, .reader-content[data-view-mode="flip-right-single"] .image-container img { max-height: calc(100vh - 120px); width: auto; } .reader-content[data-view-mode="flip-left-double"] .image-container img, .reader-content[data-view-mode="flip-right-double"] .image-container img { max-height: 100%; width: auto; object-fit: contain; } .reader-content[data-view-mode="scroll-right"] .image-container img, .reader-content[data-view-mode="scroll-left"] .image-container img { height: 100%; width: auto; max-width: none; object-fit: contain; } .image-loader { padding: 40px; text-align: center; color: var(--text-secondary); font-size: 14px; } .image-loader.error { color: var(--error); } .reader-controls { display: flex; align-items: center; justify-content: center; gap: 12px; } .nav-btn { width: 32px; height: 32px; border: none; background: var(--primary-color); color: white; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; box-shadow: var(--shadow-1); } .nav-btn:hover { background: var(--primary-dark); box-shadow: var(--shadow-2); } .nav-btn:active { transform: scale(0.95); } .nav-btn svg { width: 18px; height: 18px; } #floor-indicator { font-size: 14px; color: var(--text-primary); font-weight: 500; min-width: 120px; text-align: center; } .reader-sidebar { flex: 0 0 var(--sidebar-width); width: var(--sidebar-width); height: 100%; display: flex; flex-direction: column; background: var(--surface); border-left: 1px solid var(--divider); position: relative; z-index: 10; transition: transform 0.3s ease, opacity 0.3s ease; transform: translateX(0); } .sidebar-top { padding: 6px 10px; border-bottom: 1px solid var(--divider); background: var(--surface); position: relative; z-index: 11; display: flex; align-items: center; justify-content: center; min-height: var(--toolbar-height); box-shadow: var(--shadow-1); } .sidebar-tabs { display: flex; gap: 6px; margin: 0; width: 100%; justify-content: center; align-items: center; height: 100%; } .tab-btn { flex: 1; padding: 6px 10px; border: none; background: var(--surface); color: var(--text-secondary); border-radius: 8px; cursor: pointer; transition: all 0.2s; font-weight: 500; font-size: 14px; box-shadow: var(--shadow-1); height: 100%; display: flex; align-items: center; justify-content: center; } .tab-btn:hover { background: var(--divider); box-shadow: var(--shadow-2); } .tab-btn.active { background: var(--primary-color); color: white; box-shadow: var(--shadow-2); } .sidebar-content { flex: 1; overflow: hidden; position: relative; } .tab-panel { display: none; height: 100%; flex-direction: column; } .tab-panel.active { display: flex; } #yamibo-reader-container.sidebar-collapsed .reader-main { flex: 1 1 auto; width: 100%; } #yamibo-reader-container.sidebar-collapsed .reader-sidebar { flex: 0 0 0; width: 0; opacity: 0; pointer-events: none; transform: translateX(32px); border-left: none; } #yamibo-reader-container.sidebar-collapsed .reader-resizer { opacity: 0; pointer-events: none; } .directory-search { padding: 16px; border-bottom: 1px solid var(--divider); display: flex; gap: 8px; } .directory-search input { flex: 1; padding: 10px 16px; border: 1px solid var(--divider); border-radius: 8px; font-size: 14px; background: var(--surface); color: var(--text-primary); transition: border-color 0.2s; } .directory-search .icon-btn { width: 40px; height: 40px; border-radius: 8px; box-shadow: none; } .directory-search .icon-btn:hover { box-shadow: none; } .directory-search input:focus { outline: none; border-color: var(--primary-color); } .directory-list { flex: 1; overflow-y: auto; padding: 12px; } .directory-item { padding: 12px 16px; margin-bottom: 8px; background: var(--background); border-radius: 8px; transition: all 0.2s; border-left: 3px solid transparent; } .directory-item:hover { background: var(--divider); transform: translateX(4px); } .directory-item.current { background: rgba(76,175,80,0.1); border-left-color: var(--primary-color); } .directory-item a { color: var(--text-primary); text-decoration: none; display: block; font-size: 14px; } .directory-item.current a { color: var(--primary-color); font-weight: 500; } .comments-list { flex: 1; overflow-y: auto; padding: 16px; } .comment-item { margin-bottom: 16px; padding: 16px; background: var(--background); border-radius: 8px; box-shadow: var(--shadow-1); } .comment-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; font-size: 13px; } .comment-author { font-weight: 600; color: var(--primary-color); } .comment-floor { color: var(--text-secondary); font-size: 12px; } .comment-time { margin-left: auto; color: var(--text-secondary); font-size: 12px; } .comment-content { font-size: 14px; line-height: 1.6; color: var(--text-primary); } /* 收藏列表样式 */ .favorites-list { flex: 1; overflow-y: auto; padding: 16px; } .favorite-item { margin-bottom: 12px; padding: 16px; background: var(--background); border-radius: 8px; box-shadow: var(--shadow-1); transition: all 0.2s ease; border-left: 3px solid transparent; } .favorite-item:hover { box-shadow: var(--shadow-2); transform: translateX(2px); } .favorite-item.current { border-left-color: var(--primary-color); background: var(--surface); } .favorite-header { margin-bottom: 8px; display: flex; flex-direction: column; gap: 4px; } .favorite-title-line { display: flex; align-items: center; gap: 8px; } .favorite-title { font-size: 14px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .favorite-author, .favorite-count { font-size: 12px; color: var(--text-secondary); } .favorite-meta { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; } .favorite-latest a { color: var(--primary-color); text-decoration: none; } .favorite-latest a:hover { text-decoration: underline; } .favorite-latest-text { color: var(--text-secondary); } .favorite-progress { font-weight: 500; color: var(--primary-color); } .favorite-time { font-size: 11px; } .favorite-actions { display: flex; gap: 8px; justify-content: flex-end; } .favorite-action-btn { padding: 6px 12px; background: var(--surface); border: 1px solid var(--divider); border-radius: 4px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; font-size: 12px; } .favorite-action-btn:hover { background: var(--primary-light); border-color: var(--primary-color); transform: scale(1.05); } .favorite-action-btn[disabled], .favorite-action-btn[disabled]:hover { opacity: 0.6; cursor: not-allowed; background: var(--surface); border-color: var(--divider); transform: none; } .favorite-action-btn svg { width: 14px; height: 14px; } .empty-message { padding: 60px 20px; text-align: center; color: var(--text-secondary); font-size: 14px; } .loading, .error, .no-comments, .content-loading { padding: 60px 20px; text-align: center; color: var(--text-secondary); font-size: 14px; } .error { color: var(--error); } .popup-menu { position: fixed; background: var(--surface); border-radius: 8px; box-shadow: var(--shadow-3); z-index: 100000; min-width: 200px; overflow: hidden; } .menu-item { padding: 12px 16px; cursor: pointer; transition: background 0.2s; font-size: 14px; color: var(--text-primary); } .menu-item:hover { background: var(--divider); } .menu-divider { height: 1px; background: var(--divider); margin: 4px 0; } .menu-settings { padding: 12px 16px 16px; display: flex; flex-direction: column; gap: 10px; } .menu-section-title { font-size: 12px; font-weight: 600; color: var(--text-secondary); letter-spacing: 0.8px; text-transform: uppercase; } .menu-range-label { display: flex; justify-content: space-between; align-items: center; font-size: 13px; color: var(--text-primary); } .menu-range-label span { font-weight: 600; color: var(--primary-color); } .menu-input-label { font-size: 13px; color: var(--text-primary); } .menu-number-input { width: 100%; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--divider); background: var(--surface); color: var(--text-primary); font-size: 13px; transition: border-color 0.2s ease, box-shadow 0.2s ease; box-sizing: border-box; } .menu-number-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(76,175,80,0.15); } .menu-hint { font-size: 12px; color: var(--text-secondary); } #menu-preload-slider { width: 100%; height: 6px; background: var(--divider); border-radius: 3px; -webkit-appearance: none; appearance: none; outline: none; } #menu-preload-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--primary-color); cursor: pointer; box-shadow: var(--shadow-1); } #menu-preload-slider::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--primary-color); cursor: pointer; border: none; box-shadow: var(--shadow-1); } .menu-cache-info { font-size: 12px; color: var(--text-secondary); } .menu-action-btn { align-self: flex-end; padding: 8px 12px; background: var(--surface); border: 1px solid var(--divider); border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.2s ease; color: var(--text-primary); box-shadow: var(--shadow-1); } .menu-action-btn:hover { background: var(--primary-light); border-color: var(--primary-color); color: var(--text-primary); box-shadow: var(--shadow-2); } #data-transfer-overlay.data-transfer-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; z-index: 100002; padding: 24px; } .data-transfer-dialog { background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-3); border-radius: 12px; width: min(520px, 100%); max-width: 90vw; display: flex; flex-direction: column; gap: 16px; padding: 20px 24px; } .data-transfer-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .data-transfer-header h3 { margin: 0; font-size: 18px; font-weight: 600; color: var(--text-primary); } .data-transfer-close { border: none; background: transparent; color: var(--text-primary); width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.2s ease, color 0.2s ease; } .data-transfer-close:hover { background: var(--divider); color: var(--primary-color); } .data-transfer-body { display: flex; flex-direction: column; gap: 8px; } #data-transfer-textarea { width: 100%; height: 220px; border: 1px solid var(--divider); border-radius: 8px; padding: 12px; resize: vertical; background: var(--background); color: var(--text-primary); font-family: Consolas, 'Courier New', monospace; font-size: 13px; line-height: 1.5; box-shadow: inset 0 1px 2px rgba(0,0,0,0.08); box-sizing: border-box; } #data-transfer-textarea:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(76,175,80,0.2); } .data-transfer-desc { font-size: 12px; color: var(--text-secondary); } .data-transfer-footer { display: flex; justify-content: flex-end; gap: 10px; } .data-transfer-btn { min-width: 88px; padding: 8px 14px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.2s ease; } .data-transfer-btn.primary { border: none; background: var(--primary-color); color: #ffffff; box-shadow: var(--shadow-1); } .data-transfer-btn.primary:hover { background: var(--primary-dark); box-shadow: var(--shadow-2); } .data-transfer-btn.secondary { border: 1px solid var(--divider); background: var(--surface); color: var(--text-primary); } .data-transfer-btn.secondary:hover { border-color: var(--primary-color); color: var(--primary-color); } ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: var(--background); } ::-webkit-scrollbar-thumb { background: var(--divider); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } @media (max-width: 1024px) { .reader-main { width: 100%; } .reader-sidebar { display: none; } } `); // ========================= // 初始化 // ========================= function init() { const parser = new ContentParser(); if (!parser.threadId) { return; } const dataStore = new DataStore(); new ReaderUI(parser, dataStore); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();