// ==UserScript== // @name V2EX Conversation Enhancer // @name:zh-CN V2EX 会话增强 // @namespace https://github.com/KnowSky404/WebTweaks // @version 1.4.3 // @description Threaded cross-page replies, client-side image previews, and navigation improvements for V2EX. // @description:zh-CN 为 V2EX 提供跨页楼中楼、浏览器侧图片预览和返回顶部功能。 // @author KnowSky404 // @match https://v2ex.com/* // @match https://*.v2ex.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @run-at document-idle // @homepageURL https://github.com/KnowSky404/WebTweaks/tree/main/scripts/v2ex-com // @supportURL https://github.com/KnowSky404/WebTweaks/issues // @updateURL https://raw.githubusercontent.com/KnowSky404/WebTweaks/main/scripts/v2ex-com/conversation-enhancer.user.js // @downloadURL https://raw.githubusercontent.com/KnowSky404/WebTweaks/main/scripts/v2ex-com/conversation-enhancer.user.js // ==/UserScript== (() => { 'use strict'; const LOG_PREFIX = '[WebTweaks:V2EX]'; const SETTINGS_VERSION = 3; const SETTINGS_KEY = 'wt-v2ex-settings'; const MAX_PAGE_REQUESTS = 3; const PAGE_TIMEOUT_MS = 12_000; const SCROLL_THRESHOLD = 600; const IMAGE_CONTENT_SELECTOR = '.topic_content, .reply_content'; const IMAGE_EXTENSION_PATTERN = /\.(?:jpe?g|png|gif|webp|avif)$/i; const IMAGE_URL_TOKEN_PATTERN = /https?:\/\/[^\s<>"']+/gi; const SETTING_KEYS = new Set([ 'version', 'preferredView', 'scrollTopEnabled', 'imagePreviewEnabled', 'imageLightboxEnabled' ]); // Verified against public V2EX HTML on 2026-08-18. Fallbacks are intentionally narrow. const SELECTORS = Object.freeze({ reply: ['#Main .cell[id^="r_"]', '#Wrapper .cell[id^="r_"]', '.cell[id^="r_"]'], replyContent: ['.reply_content'], replyAuthor: ['strong a.dark', 'strong a[href^="/member/"]'], replyAvatar: ['img.avatar'], replyFloor: ['.no'], replyTime: ['.ago'], replyThanks: ['.small.fade img[alt="❤️"]'], nativeReplyFloor: ['.fr .no', '.no'], pagination: ['.ps_container a[href*="?p="]', '.ps_container a[href*="&p="]'], pageInput: ['.ps_container input.page_input'], editor: ['#reply_content', '#topic_content', 'textarea[name="content"]'], nativeReplyAction: ['a.reply', '.reply a', '[data-action="reply"]'] }); const runtime = { settings: loadSettings(), topic: null, controlDock: null, conversationToggle: null, conversationPanel: null, panelDismissalAttached: false, scrollButton: null, scrollFrame: 0, nativeReplyListenerAttached: false, navigationAttached: false, navigationSchedule: 0, navigationSync: false, navigationRevision: 0, observedTopicKey: null, imageDelegationAttached: false, imageLightbox: null, imageLightboxImage: null, imageLightboxLink: null, imageLightboxClose: null, imageLightboxOpener: null, imageLightboxFocusFrame: 0, imageScrollLock: null }; function logError(error) { if (error && error.name === 'AbortError') return; console.error(LOG_PREFIX, error instanceof Error ? error.message : error); } function safeGet(key, fallback) { try { return typeof GM_getValue === 'function' ? GM_getValue(key, fallback) : fallback; } catch (error) { logError(error); return fallback; } } function safeSet(key, value) { try { if (typeof GM_setValue === 'function') GM_setValue(key, value); } catch (error) { logError(error); } } function loadSettings() { const stored = safeGet(SETTINGS_KEY, {}); const value = stored && typeof stored === 'object' ? stored : {}; const settings = { version: SETTINGS_VERSION, preferredView: value.preferredView === 'original' ? 'original' : 'threaded', scrollTopEnabled: value.scrollTopEnabled !== false, imagePreviewEnabled: value.imagePreviewEnabled !== false, imageLightboxEnabled: value.imageLightboxEnabled !== false }; if (value.version !== SETTINGS_VERSION || Object.keys(value).some((key) => !SETTING_KEYS.has(key))) { safeSet(SETTINGS_KEY, settings); } return settings; } function saveSettings() { safeSet(SETTINGS_KEY, { ...runtime.settings, version: SETTINGS_VERSION }); } function registerMenuCommands() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('开启/关闭评论图片自动展开', () => { runtime.settings.imagePreviewEnabled = !runtime.settings.imagePreviewEnabled; saveSettings(); if (runtime.settings.imagePreviewEnabled) scanImageContent(document); else { if (runtime.imageLightboxOpener?.classList.contains('wt-v2ex-inline-image')) closeImageLightbox(); resetImagePreviews(document); } }); GM_registerMenuCommand('开启/关闭图片点击放大', () => { runtime.settings.imageLightboxEnabled = !runtime.settings.imageLightboxEnabled; saveSettings(); if (runtime.settings.imageLightboxEnabled) scanImageContent(document); else { closeImageLightbox(); resetLightboxTargets(document); } }); GM_registerMenuCommand('开启/关闭返回顶部', () => { runtime.settings.scrollTopEnabled = !runtime.settings.scrollTopEnabled; saveSettings(); if (runtime.settings.scrollTopEnabled) initializeScrollToTop(); updateScrollButton(); }); GM_registerMenuCommand('默认使用楼中楼/原始顺序', () => { runtime.settings.preferredView = runtime.settings.preferredView === 'threaded' ? 'original' : 'threaded'; saveSettings(); if (runtime.topic) applyTopicView(runtime.topic, runtime.settings.preferredView); }); } function firstMatch(root, selectors) { for (const selector of selectors) { const match = root.querySelector(selector); if (match) return match; } return null; } function allMatches(root, selectors) { const seen = new Set(); const matches = []; for (const selector of selectors) { root.querySelectorAll(selector).forEach((element) => { if (!seen.has(element)) { seen.add(element); matches.push(element); } }); } return matches; } function topicContext() { const match = location.pathname.match(/^\/t\/(\d+)\/?$/); if (!match) return null; const rawPage = new URL(location.href).searchParams.get('p'); const parsedPage = Number.parseInt(rawPage || '1', 10); const page = Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1; return { id: match[1], page, key: `${location.origin}/t/${match[1]}?p=${page}` }; } function topicSnapshotMatches(topic, context, navigationRevision = runtime.navigationRevision) { return Boolean( topic && context && topic.navigationRevision === navigationRevision && context.key === topic.key && context.page === topic.currentPage ); } function isCurrentTopic(topic, context = topicContext()) { return runtime.topic === topic && topicSnapshotMatches(topic, context); } function topicDomMatchesContext(context) { if (!context || !allMatches(document, SELECTORS.reply).length) return false; const currentPage = firstMatch(document, ['.ps_container .page_current']); if (!currentPage) return true; return parseFloor(currentPage.textContent) === context.page; } function waitForTopicDom(context, timeoutMs = 2_000) { if (topicDomMatchesContext(context)) return Promise.resolve(true); return new Promise((resolve) => { let settled = false; const finish = (ready) => { if (settled) return; settled = true; observer.disconnect(); window.clearTimeout(timer); resolve(ready); }; const check = () => { const liveContext = topicContext(); if (liveContext?.key !== context.key) finish(false); else if (topicDomMatchesContext(context)) finish(true); }; const observer = new MutationObserver(check); observer.observe(document.body || document.documentElement, { childList: true, subtree: true }); const timer = window.setTimeout(() => finish(false), timeoutMs); check(); }); } function topicStateMatchesLocation(context = topicContext()) { return runtime.topic ? isCurrentTopic(runtime.topic, context) : !context; } function discoverPages(context) { const pageNumbers = new Set([context.page]); const links = allMatches(document, SELECTORS.pagination); links.forEach((link) => { const page = Number(new URL(link.href, location.href).searchParams.get('p')); if (Number.isInteger(page) && page > 0) pageNumbers.add(page); }); const pageInput = firstMatch(document, SELECTORS.pageInput); const inputMax = Number(pageInput?.max); if (Number.isInteger(inputMax) && inputMax > 0) pageNumbers.add(inputMax); const pages = [...pageNumbers].sort((a, b) => a - b); return { pages, maxPage: Math.max(...pages) }; } function pageUrl(context, page) { const url = new URL(location.href); url.pathname = `/t/${context.id}`; url.search = page === 1 ? '' : `?p=${page}`; url.hash = ''; return url.href; } function normalizeName(value) { return typeof value === 'string' ? value.trim().toLocaleLowerCase() : ''; } function parseFloor(value) { const match = String(value || '').match(/\d+/); const floor = match ? Number(match[0]) : NaN; return Number.isInteger(floor) && floor > 0 ? floor : null; } function parseLeadingPrefix(content) { const text = (content.textContent || '').replace(/\u00a0/g, ' '); let rest = text.replace(/^\s+/, ''); const mentions = []; let explicitReplyFloor = null; const memberLinks = [...content.querySelectorAll('a[href^="/member/"]')]; let memberIndex = 0; while (rest.startsWith('@')) { const match = rest.match(/^@([^\s#@]+)(?:\s+#(\d+))?/); if (!match) break; const name = match[1]; const linked = memberLinks[memberIndex]; if (linked && normalizeName(linked.textContent) !== normalizeName(name)) break; mentions.push({ name, normalized: normalizeName(name) }); memberIndex += 1; explicitReplyFloor = match[2] ? Number(match[2]) : null; rest = rest.slice(match[0].length).replace(/^\s+/, ''); if (!rest.startsWith('@')) break; } return { mentionedUsers: mentions.map((mention) => mention.name), normalizedMentions: mentions, explicitReplyFloor: mentions.length === 1 ? explicitReplyFloor : null, hasMultipleStructuralMentions: mentions.length > 1, hasStructuralMention: mentions.length > 0 }; } function safeUrl(raw, kind, base = location.href) { if (!raw) return null; try { const url = new URL(raw, base); if (!['http:', 'https:'].includes(url.protocol)) return null; if (kind === 'image' && !['http:', 'https:'].includes(url.protocol)) return null; return url.href; } catch (error) { return null; } } function imagePreviewUrl(raw, base = location.href) { const safe = safeUrl(raw, 'image', base); if (!safe) return null; try { const url = new URL(safe); return IMAGE_EXTENSION_PATTERN.test(url.pathname) ? url.href : null; } catch (error) { return null; } } function imageUrlsInText(value, base = location.href) { const urls = []; const seen = new Set(); for (const match of String(value || '').matchAll(IMAGE_URL_TOKEN_PATTERN)) { const candidate = match[0].replace(/[)\]},.;!?,。;!?]+$/u, ''); const url = imagePreviewUrl(candidate, base); if (url && !seen.has(url)) { seen.add(url); urls.push(url); } } return urls; } function sanitizeContent(element, base = location.href) { const clone = element.cloneNode(true); clone.querySelectorAll('script, style, iframe, form, object, embed, template, base, meta, link, input, select, textarea, button').forEach((node) => node.remove()); clone.querySelectorAll('*').forEach((node) => { [...node.attributes].forEach((attribute) => { const name = attribute.name.toLowerCase(); const value = attribute.value.trim(); if (name.startsWith('on') || name === 'id' || name === 'srcset' || name === 'srcdoc' || name === 'style') node.removeAttribute(attribute.name); if ((name === 'href' || name === 'src' || name === 'action') && !safeUrl(value, name === 'src' ? 'image' : 'link', base)) { node.removeAttribute(attribute.name); } if (name === 'href' || name === 'src') { const safe = safeUrl(value, name === 'src' ? 'image' : 'link', base); if (safe) node.setAttribute(name, safe); } }); if (node.matches('a')) { const href = safeUrl(node.getAttribute('href'), 'link', base); if (!href) node.removeAttribute('href'); else if (new URL(href).origin !== location.origin) node.setAttribute('rel', 'noopener noreferrer'); } if (node.matches('img')) { const src = safeUrl(node.getAttribute('src'), 'image', base); if (!src) node.remove(); else { node.setAttribute('src', src); node.setAttribute('loading', 'lazy'); } } }); return clone.innerHTML; } function sanitizeReplyTemplate(element, base = location.href) { const clone = element.cloneNode(true); clone.removeAttribute('id'); clone.querySelectorAll('script, style, iframe, form, object, embed, template, base, meta, link, input, select, textarea, button, [data-action], a.reply, .reply a, .thank, .ignore, .moderate, .small.fade').forEach((node) => node.remove()); clone.querySelectorAll('[id]').forEach((node) => node.removeAttribute('id')); clone.querySelectorAll('*').forEach((node) => { [...node.attributes].forEach((attribute) => { const name = attribute.name.toLowerCase(); const value = attribute.value.trim(); if (name.startsWith('on') || name === 'srcset' || name === 'srcdoc' || name === 'style' || name === 'formaction') { node.removeAttribute(attribute.name); return; } if (name === 'href' || name === 'src') { const safe = safeUrl(value, name === 'src' ? 'image' : 'link', base); if (!safe) node.removeAttribute(attribute.name); else node.setAttribute(name, safe); } }); if (node.matches('a')) { const href = safeUrl(node.getAttribute('href'), 'link', base); if (!href) node.removeAttribute('href'); else if (new URL(href).origin !== location.origin) node.setAttribute('rel', 'noopener noreferrer'); } if (node.matches('img')) { const src = safeUrl(node.getAttribute('src'), 'image', base); if (!src) node.remove(); else node.setAttribute('loading', 'lazy'); } }); return clone; } function nativeReplyImage(group) { return [...group.querySelectorAll('img')].find((image) => { const alt = (image.getAttribute('alt') || '').trim().toLocaleLowerCase(); const rawSrc = image.getAttribute('src') || ''; try { const url = new URL(rawSrc, location.href); const filename = url.pathname.split('/').pop() || ''; return isV2exAssetUrl(url) && (alt === 'reply' || /^reply(?:[._-]|$)/i.test(filename)); } catch (error) { return false; } }); } function isV2exAssetUrl(raw, base = location.href) { try { const url = raw instanceof URL ? raw : new URL(raw, base); return url.hostname === 'v2ex.com' || url.hostname.endsWith('.v2ex.com'); } catch (error) { return false; } } function hasNativeReplyHandler(element) { return [...element.attributes].some((attribute) => { if (!attribute.name.toLowerCase().startsWith('on') && attribute.name.toLowerCase() !== 'href') return false; return /(?:replyOne|reply(?:Action|Reply))\s*\(/i.test(attribute.value); }); } function nativeReplyAction(group) { const image = nativeReplyImage(group); if (image) { const clickable = image.closest('a, button, [role="button"]'); if (clickable && group.contains(clickable)) return clickable; } for (const selector of SELECTORS.nativeReplyAction) { const match = group.querySelector(selector); if (match) return match.closest('a, button, [role="button"]') || match; } return [...group.querySelectorAll('a, button, [role="button"]')].find(hasNativeReplyHandler) || null; } function nativeReplyGroup(root) { const groups = [...root.querySelectorAll('.fr')]; return groups.find((group) => nativeReplyAction(group)) || groups.find((group) => group.querySelector('.no')) || null; } function sanitizeNativeControlNode(node, base, restrictImages = false) { const clone = node.cloneNode(true); clone.querySelectorAll('script, style, iframe, object, embed, form, input, select, textarea, template, base, meta, link').forEach((unsafeNode) => unsafeNode.remove()); const nodes = [clone, ...clone.querySelectorAll('*')]; nodes.forEach((current) => { [...current.attributes].forEach((attribute) => { const name = attribute.name.toLowerCase(); const value = attribute.value.trim(); if (name.startsWith('on') || name === 'id' || name === 'srcset' || name === 'srcdoc' || name === 'data-action' || name === 'formaction') { current.removeAttribute(attribute.name); return; } if (name === 'href' || name === 'src') { if (!safeUrl(value, name === 'src' ? 'image' : 'link', base) || (restrictImages && name === 'src' && current.matches('img') && !isV2exAssetUrl(value, base))) current.removeAttribute(attribute.name); else current.setAttribute(name, value); } }); }); return clone; } function extractNativeReplyControls(element, base = location.href) { const group = nativeReplyGroup(element); if (!group) return null; const action = nativeReplyAction(group); const floor = group.querySelector('.no'); if (!action && !floor) return null; const controls = document.createElement('div'); controls.className = 'fr wt-v2ex-native-reply-controls'; if (action) { const actionClone = sanitizeNativeControlNode(action, base, true); const hasReplyImage = actionClone.matches('img') || actionClone.querySelector('img'); if (hasReplyImage) { const interactive = actionClone.matches('a, button, [role="button"]') ? actionClone : actionClone.querySelector('a, button, [role="button"]'); if (interactive) { interactive.classList.add('wt-v2ex-native-reply-action'); interactive.dataset.wtV2exNativeReplyAction = 'true'; if (interactive.matches('a') && !interactive.getAttribute('href')) interactive.setAttribute('href', '#'); if (interactive.matches('[role="button"]') && !interactive.hasAttribute('tabindex')) interactive.setAttribute('tabindex', '0'); } else { const button = document.createElement('button'); button.type = 'button'; button.className = 'wt-v2ex-native-reply-action'; button.dataset.wtV2exNativeReplyAction = 'true'; button.append(actionClone); controls.append(button); } if (interactive) controls.append(actionClone); } } if (floor) controls.append(sanitizeNativeControlNode(floor, base)); return controls.childElementCount ? controls : null; } function parseReply(element, page, index = 0, base = location.href) { const content = firstMatch(element, SELECTORS.replyContent); if (!content) return null; const idMatch = String(element.id || '').match(/^r_(.+)$/); const floor = parseFloor(firstMatch(element, SELECTORS.replyFloor)?.textContent); const authorElement = firstMatch(element, SELECTORS.replyAuthor); const author = (authorElement?.textContent || '').trim(); const thanksImage = firstMatch(element, SELECTORS.replyThanks); const thanksText = thanksImage?.parentElement?.textContent || ''; const thankCount = thanksText.match(/\d+/)?.[0]; const prefix = parseLeadingPrefix(content); return { id: idMatch ? idMatch[1] : `fallback-${page}-${floor || 'unknown'}-${index}`, floor, page, author, normalizedAuthor: normalizeName(author), avatarUrl: safeUrl(firstMatch(element, SELECTORS.replyAvatar)?.getAttribute('src'), 'image', base), timeText: firstMatch(element, SELECTORS.replyTime)?.getAttribute('title') || firstMatch(element, SELECTORS.replyTime)?.textContent.trim() || '', contentHtml: sanitizeContent(content, base), contentText: (content.textContent || '').trim(), thankCount: thankCount ? Number(thankCount) : null, mentionedUsers: prefix.mentionedUsers, normalizedMentions: prefix.normalizedMentions, explicitReplyFloor: prefix.explicitReplyFloor, hasMultipleStructuralMentions: prefix.hasMultipleStructuralMentions, hasStructuralMention: prefix.hasStructuralMention, parentId: null, relationshipConfidence: 'root', unresolvedReason: '', children: [], nativeTemplate: sanitizeReplyTemplate(element, base), nativeReplyControlsTemplate: extractNativeReplyControls(element, base) }; } function parseReplies(root, page, base = location.href) { const elements = root instanceof Document ? allMatches(root, SELECTORS.reply) : allMatches(root, SELECTORS.reply); return elements.map((element, index) => parseReply(element, page, index, base)).filter(Boolean); } function inferRelationships(replies) { const sorted = [...replies].sort((a, b) => (a.floor ?? Number.MAX_SAFE_INTEGER) - (b.floor ?? Number.MAX_SAFE_INTEGER)); const replyByFloor = new Map(); const earlierRepliesByNormalizedAuthor = new Map(); const duplicateFloors = new Set(); sorted.forEach((reply) => { reply.children = []; reply.parentId = null; if (reply.floor !== null) { if (replyByFloor.has(reply.floor)) duplicateFloors.add(reply.floor); else replyByFloor.set(reply.floor, reply); } }); sorted.forEach((reply) => { const mention = reply.normalizedMentions[0]; let parent = null; if (reply.hasMultipleStructuralMentions) { reply.relationshipConfidence = 'unresolved'; reply.unresolvedReason = '开头包含多个用户,无法确定唯一父楼层'; } else if (reply.explicitReplyFloor !== null) { const target = replyByFloor.get(reply.explicitReplyFloor); if (duplicateFloors.has(reply.explicitReplyFloor)) { reply.relationshipConfidence = 'unresolved'; reply.unresolvedReason = `引用的 #${reply.explicitReplyFloor} 不唯一`; } else if (!target) { reply.relationshipConfidence = 'unresolved'; reply.unresolvedReason = `引用的 #${reply.explicitReplyFloor} 不存在`; } else if (target.floor >= reply.floor) { reply.relationshipConfidence = 'unresolved'; reply.unresolvedReason = '引用了当前或未来楼层'; } else if (!mention || target.normalizedAuthor !== mention.normalized) { reply.relationshipConfidence = 'unresolved'; reply.unresolvedReason = '引用楼层作者与用户前缀不一致'; } else { parent = target; reply.relationshipConfidence = 'exact'; } } else if (reply.hasStructuralMention && mention) { parent = earlierRepliesByNormalizedAuthor.get(mention.normalized) || null; if (parent) reply.relationshipConfidence = 'inferred'; else { reply.relationshipConfidence = 'unresolved'; reply.unresolvedReason = '没有找到更早的同名作者楼层'; } } else { reply.relationshipConfidence = 'root'; } if (parent && parent !== reply && parent.floor < reply.floor) { reply.parentId = parent.id; parent.children.push(reply); } else if (parent) { reply.relationshipConfidence = 'unresolved'; reply.unresolvedReason = '父楼层必须早于当前楼层'; } if (reply.normalizedAuthor) earlierRepliesByNormalizedAuthor.set(reply.normalizedAuthor, reply); }); return sorted; } function deriveVisibleThread(topic) { const allReplies = topic.models; const replyById = new Map(allReplies.map((reply) => [reply.id, reply])); const currentPageReplies = allReplies.filter((reply) => reply.page === topic.currentPage); const visibleIds = new Set(currentPageReplies.map((reply) => reply.id)); currentPageReplies.forEach((reply) => { const visited = new Set([reply.id]); let parentId = reply.parentId; while (parentId && !visited.has(parentId)) { visited.add(parentId); const parent = replyById.get(parentId); if (!parent) break; visibleIds.add(parent.id); parentId = parent.parentId; } }); const childrenByParent = new Map(); const roots = []; const rootIds = new Set(); allReplies.forEach((reply) => { if (!visibleIds.has(reply.id)) return; if (reply.parentId && visibleIds.has(reply.parentId)) { const children = childrenByParent.get(reply.parentId) || []; children.push(reply); childrenByParent.set(reply.parentId, children); } else if (!rootIds.has(reply.id)) { rootIds.add(reply.id); roots.push(reply); } }); const firstCurrentFloorByRoot = new Map(); currentPageReplies.forEach((reply) => { let root = reply; const visited = new Set(); while (root.parentId && visibleIds.has(root.parentId)) { if (visited.has(root.id)) { if (!rootIds.has(root.id)) { rootIds.add(root.id); roots.push(root); } break; } visited.add(root.id); const parent = replyById.get(root.parentId); if (!parent) break; root = parent; } const floor = reply.floor === null ? Number.MAX_SAFE_INTEGER : reply.floor; const previous = firstCurrentFloorByRoot.get(root.id); if (previous === undefined || floor < previous) firstCurrentFloorByRoot.set(root.id, floor); }); roots.sort((left, right) => { const leftFloor = firstCurrentFloorByRoot.get(left.id) ?? Number.MAX_SAFE_INTEGER; const rightFloor = firstCurrentFloorByRoot.get(right.id) ?? Number.MAX_SAFE_INTEGER; return leftFloor - rightFloor || (left.floor ?? Number.MAX_SAFE_INTEGER) - (right.floor ?? Number.MAX_SAFE_INTEGER); }); return { currentPageReplies, visibleIds, visibleReplies: allReplies.filter((reply) => visibleIds.has(reply.id)), roots, childrenByParent }; } async function fetchPage(url, retry = 0) { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), PAGE_TIMEOUT_MS); try { const response = await fetch(url, { credentials: 'same-origin', signal: controller.signal }); if (!response.ok) { if (retry < 1 && response.status >= 500) return fetchPage(url, retry + 1); throw new Error(`页面加载失败(HTTP ${response.status})`); } return await response.text(); } catch (error) { if (retry < 1 && (error.name === 'TypeError' || error.name === 'AbortError')) return fetchPage(url, retry + 1); throw error; } finally { window.clearTimeout(timer); } } async function loadPages(topic, pages, onProgress) { const results = []; let nextIndex = 0; let completed = 0; async function worker() { while (nextIndex < pages.length) { const index = nextIndex; nextIndex += 1; const page = pages[index]; try { const html = await fetchPage(pageUrl(topic.context, page)); const parsed = new DOMParser().parseFromString(html, 'text/html'); const replies = parseReplies(parsed, page, pageUrl(topic.context, page)); if (!replies.length) throw new Error('分页中未找到可解析的回复'); replies.forEach((reply) => { if (!reply.nativeReplyControlsTemplate && topic.nativeReplyControlsTemplate) { reply.nativeReplyControlsTemplate = topic.nativeReplyControlsTemplate.cloneNode(true); } }); results[index] = { page, replies }; } catch (error) { results[index] = { page, error }; } finally { completed += 1; onProgress(completed, pages.length); } } } await Promise.all(Array.from({ length: Math.min(MAX_PAGE_REQUESTS, pages.length) }, worker)); return results; } function ensureControlDock() { if (runtime.controlDock?.isConnected) return runtime.controlDock; const dock = document.createElement('div'); dock.id = 'wt-v2ex-control-dock'; dock.className = 'wt-v2ex-control-dock'; dock.setAttribute('aria-label', 'V2EX 页面工具'); document.body.append(dock); runtime.controlDock = dock; return dock; } function setConversationPanelOpen(topic, open) { const panel = topic.panel || runtime.conversationPanel; const toggle = runtime.conversationToggle; if (!panel || !toggle) return; panel.hidden = !open; toggle.setAttribute('aria-expanded', String(open)); toggle.setAttribute('aria-label', open ? '关闭会话视图控制面板' : '打开会话视图控制面板'); } function createConversationControl(topic) { const dock = ensureControlDock(); if (runtime.conversationToggle?.isConnected) { topic.panel = runtime.conversationPanel; updateTopicPanel(topic); return; } const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'wt-v2ex-conversation-toggle'; toggle.textContent = '会话'; toggle.setAttribute('aria-label', '打开会话视图控制面板'); toggle.setAttribute('aria-expanded', 'false'); toggle.setAttribute('aria-controls', 'wt-v2ex-conversation-panel'); const panel = document.createElement('div'); panel.id = 'wt-v2ex-conversation-panel'; panel.className = 'wt-v2ex-conversation-panel'; panel.setAttribute('role', 'region'); panel.setAttribute('aria-label', '会话视图'); panel.hidden = true; panel.innerHTML = `
会话视图
准备构建楼中楼
`; panel.addEventListener('click', (event) => { const button = event.target.closest('button'); if (!button || !panel.contains(button)) return; if (button.dataset.view) { runtime.settings.preferredView = button.dataset.view; saveSettings(); applyTopicView(topic, button.dataset.view); } else if (button.classList.contains('wt-v2ex-load-pages')) { void loadAllTopicPages(topic).catch((error) => { if (!isCurrentTopic(topic)) return; topic.loading = false; topic.failed = true; setTopicStatus(topic, '分页解析失败,已保留原始顺序'); applyTopicView(topic, 'original'); logError(error); }); } }); toggle.addEventListener('click', () => setConversationPanelOpen(topic, panel.hidden)); dock.prepend(panel, toggle); runtime.conversationToggle = toggle; runtime.conversationPanel = panel; topic.panel = panel; if (!runtime.panelDismissalAttached) { document.addEventListener('pointerdown', (event) => { if (!runtime.conversationPanel || runtime.conversationPanel.hidden || !runtime.controlDock?.contains(event.target)) { if (runtime.topic) setConversationPanelOpen(runtime.topic, false); } }, true); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && runtime.topic) setConversationPanelOpen(runtime.topic, false); }); runtime.panelDismissalAttached = true; } updateTopicPanel(topic); } function setTopicStatus(topic, message) { topic.statusMessage = message; const status = topic.panel?.querySelector('.wt-v2ex-thread-status'); if (status) status.textContent = message; } function updateTopicPanel(topic) { const panel = topic.panel; if (!panel) return; panel.querySelectorAll('[data-view]').forEach((button) => { button.setAttribute('aria-pressed', String(button.dataset.view === topic.view)); }); const loadButton = panel.querySelector('.wt-v2ex-load-pages'); if (loadButton) { loadButton.hidden = !topic.incomplete || topic.loading; loadButton.textContent = topic.loading ? '正在加载…' : `加载全部 ${topic.maxPage} 页并构建楼中楼`; } const status = panel.querySelector('.wt-v2ex-thread-status'); if (status) status.textContent = topic.statusMessage || '准备构建楼中楼'; } function relationshipLabel(reply) { if (reply.relationshipConfidence === 'exact') return `回复 #${reply.explicitReplyFloor}`; if (reply.relationshipConfidence === 'inferred') return '推断回复'; if (reply.relationshipConfidence === 'unresolved') return '关系未确定'; return ''; } function removeClonedNativeHeaderControls(header) { const groups = [...header.querySelectorAll('.fr')]; let removedControlGroup = false; groups.forEach((group) => { if (nativeReplyAction(group) || group.querySelector('.no')) { group.remove(); removedControlGroup = true; } }); allMatches(header, SELECTORS.nativeReplyAction).forEach((action) => action.remove()); if (removedControlGroup) allMatches(header, SELECTORS.nativeReplyFloor).forEach((floor) => floor.remove()); else firstMatch(header, SELECTORS.nativeReplyFloor)?.remove(); [...header.querySelectorAll('.reply, .fr')].reverse().forEach((wrapper) => { if (!wrapper.children.length && !wrapper.textContent.trim()) wrapper.remove(); }); } function createReplyCard(reply, depth, hasVisibleChildren) { const cell = reply.nativeTemplate?.cloneNode(true) || document.createElement('div'); if (!cell.classList.contains('cell')) cell.classList.add('cell'); cell.classList.add('wt-v2ex-threaded-reply', `wt-v2ex-depth-${Math.min(depth, 6)}`); cell.removeAttribute('id'); cell.dataset.wtV2exReplyId = reply.id; cell.setAttribute('aria-label', `第 ${reply.floor || '未知'} 楼,${reply.author || '未知用户'}`); const content = firstMatch(cell, SELECTORS.replyContent); const contentCell = content?.closest('td') || cell; const separator = [...contentCell.children].find((element) => element.classList.contains('sep5')); const header = document.createElement('div'); header.className = 'wt-v2ex-reply-header'; const headerNodes = []; for (let node = contentCell.firstChild; node && node !== separator && node !== content; node = node.nextSibling) { headerNodes.push(node); } if (headerNodes.length) { contentCell.insertBefore(header, headerNodes[0]); headerNodes.forEach((node) => header.append(node)); } else { contentCell.insertBefore(header, separator || content || contentCell.firstChild); } removeClonedNativeHeaderControls(header); const relation = relationshipLabel(reply); if (relation) { const badge = document.createElement('span'); badge.className = `wt-v2ex-relation wt-v2ex-relation-${reply.relationshipConfidence}`; badge.textContent = relation; if (reply.unresolvedReason) badge.title = reply.unresolvedReason; header.append(badge); } const controls = reply.nativeReplyControlsTemplate?.cloneNode(true); if (controls) { const nativeAction = controls.querySelector('[data-wt-v2ex-native-reply-action="true"]'); const nativeFloor = controls.querySelector('.no'); if (nativeFloor) { if (reply.floor) nativeFloor.textContent = String(reply.floor); else nativeFloor.remove(); } if (nativeAction) { nativeAction.dataset.replyUser = reply.author; nativeAction.dataset.replyFloor = String(reply.floor || ''); nativeAction.setAttribute('aria-label', `回复 ${reply.author || '该用户'} 第 ${reply.floor || ''} 楼`); } if (controls.childElementCount) header.append(controls); } if (hasVisibleChildren) { const children = document.createElement('div'); children.className = `wt-v2ex-thread-children wt-v2ex-child-depth-${Math.min(depth + 1, 6)}`; cell.append(children); return { cell, children }; } return { cell, children: null }; } function captureThreadedScrollAnchor(topic) { if (topic.view !== 'threaded' || topic.threadedView.hidden) return null; const viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0; const anchor = [...topic.threadedView.querySelectorAll('[data-wt-v2ex-reply-id]')].find((element) => { const rect = element.getBoundingClientRect(); return rect.bottom > 0 && rect.top < viewportBottom; }); if (!anchor) return null; const rect = anchor.getBoundingClientRect(); return { id: anchor.dataset.wtV2exReplyId, top: rect.top }; } function restoreThreadedScrollAnchor(topic, anchor) { if (!anchor || typeof window.scrollBy !== 'function') return; const replacement = [...topic.threadedView.querySelectorAll('[data-wt-v2ex-reply-id]')] .find((element) => element.dataset.wtV2exReplyId === anchor.id); if (!replacement) return; const delta = replacement.getBoundingClientRect().top - anchor.top; if (Number.isFinite(delta) && Math.abs(delta) > 0.5) window.scrollBy(0, delta); } function renderThreadedView(topic) { const lightboxUrl = runtime.imageLightboxOpener && topic.threadedView.contains(runtime.imageLightboxOpener) ? lightboxUrlForImage(runtime.imageLightboxOpener) : null; if (lightboxUrl) closeImageLightbox(false); const scrollAnchor = captureThreadedScrollAnchor(topic); topic.threadedView.replaceChildren(); const fragment = document.createDocumentFragment(); const visibleThread = topic.visibleThread || deriveVisibleThread(topic); const stack = [...visibleThread.roots].reverse().map((reply) => ({ reply, target: fragment, depth: 0 })); const renderedReplyIds = new Set(); while (stack.length) { const { reply, target, depth } = stack.pop(); if (renderedReplyIds.has(reply.id)) { console.warn(`${LOG_PREFIX} skipped duplicate threaded reply`, reply.id); continue; } renderedReplyIds.add(reply.id); const children = visibleThread.childrenByParent.get(reply.id) || []; const rendered = createReplyCard(reply, depth, children.length > 0); target.append(rendered.cell); if (children.length && rendered.children) { for (let index = children.length - 1; index >= 0; index -= 1) { stack.push({ reply: children[index], target: rendered.children, depth: depth + 1 }); } } } topic.threadedView.append(fragment); restoreThreadedScrollAnchor(topic, scrollAnchor); scanImageContent(topic.threadedView); if (lightboxUrl) { const replacement = [...topic.threadedView.querySelectorAll('img')].find((image) => lightboxUrlForImage(image) === lightboxUrl); replacement?.focus({ preventScroll: true }); } } function applyTopicView(topic, view) { if (view === 'threaded' && (!topic.threadedView.childElementCount || topic.failed)) { view = 'original'; } topic.view = view; topic.threadedView.hidden = view !== 'threaded'; topic.threadedView.setAttribute('aria-hidden', String(view !== 'threaded')); topic.nativeReplies.forEach((reply) => { const hidden = view === 'threaded'; reply.classList.toggle('wt-v2ex-native-hidden', hidden); if (hidden) reply.setAttribute('aria-hidden', 'true'); else reply.removeAttribute('aria-hidden'); }); updateTopicPanel(topic); } async function loadAllTopicPages(topic, pageLoader = loadPages) { if (topic.loading || !topic.incomplete || !isCurrentTopic(topic)) return; topic.loading = true; updateTopicPanel(topic); setTopicStatus(topic, `正在加载 0 / ${topic.maxPage} 页`); const pages = topic.pages.filter((page) => !topic.loadedPages.has(page)); const results = await pageLoader(topic, pages, (completed, total) => { if (isCurrentTopic(topic)) setTopicStatus(topic, `正在加载 ${completed} / ${total} 页`); }); if (!isCurrentTopic(topic)) { topic.loading = false; return; } let failures = 0; results.forEach((result) => { if (result?.error) failures += 1; else if (result) { topic.loadedPages.add(result.page); topic.allReplies.push(...result.replies); } }); const byId = new Map(); const deduped = []; topic.allReplies.forEach((reply) => { if (byId.has(reply.id)) { topic.duplicateIds += 1; return; } byId.set(reply.id, reply); deduped.push(reply); }); topic.allReplies = deduped; topic.incomplete = failures > 0; topic.loading = false; topic.failed = topic.allReplies.length === 0; topic.models = inferRelationships(topic.allReplies); topic.visibleThread = deriveVisibleThread(topic); renderThreadedView(topic); const visibleCount = topic.visibleThread.visibleReplies.length; const visibleSummary = `当前线程投影包含 ${visibleCount} 条回复(含必要跨页上下文)`; if (failures) setTopicStatus(topic, `部分分页加载失败,已加载 ${topic.models.length} 条关系数据;${visibleSummary},原始顺序仍可用`); else if (topic.duplicateIds) setTopicStatus(topic, `已加载 ${topic.models.length} 条关系数据;${visibleSummary}(忽略 ${topic.duplicateIds} 个重复 ID)`); else setTopicStatus(topic, `已加载 ${topic.models.length} 条关系数据;${visibleSummary},关系为启发式重建`); updateTopicPanel(topic); if (failures || topic.failed) applyTopicView(topic, 'original'); else if (runtime.settings.preferredView === 'threaded') applyTopicView(topic, 'threaded'); } async function initializeTopic() { const context = topicContext(); if (!context) return; if (runtime.topic?.key === context.key) return; const nativeReplies = allMatches(document, SELECTORS.reply); if (!nativeReplies.length) return; const firstReply = nativeReplies[0]; const threadedView = document.createElement('section'); threadedView.className = 'wt-v2ex-threaded-view'; threadedView.setAttribute('aria-label', '楼中楼视图'); const discoveredPages = discoverPages(context); const topic = { context, key: context.key, currentPage: context.page, navigationRevision: runtime.navigationRevision, nativeReplies, panel: null, threadedView, pages: discoveredPages.pages, maxPage: discoveredPages.maxPage, loadedPages: new Set([context.page]), allReplies: parseReplies(document, context.page, location.href), nativeReplyControlsTemplate: null, models: [], view: 'original', loading: false, incomplete: false, failed: false, duplicateIds: 0, statusMessage: '准备构建楼中楼' }; topic.nativeReplyControlsTemplate = topic.allReplies.find((reply) => reply.nativeReplyControlsTemplate)?.nativeReplyControlsTemplate || null; firstReply.before(threadedView); runtime.topic = topic; topic.models = inferRelationships(topic.allReplies); topic.visibleThread = deriveVisibleThread(topic); renderThreadedView(topic); if (topic.maxPage > 10) { topic.incomplete = true; setTopicStatus(topic, `当前显示第 ${topic.currentPage} 页;完整关系需要加载全部 ${topic.maxPage} 页`); updateTopicPanel(topic); } else if (topic.pages.length > 1) { topic.incomplete = true; updateTopicPanel(topic); setTopicStatus(topic, `正在加载 0 / ${topic.pages.length - 1} 页`); void loadAllTopicPages(topic).catch((error) => { if (!isCurrentTopic(topic)) return; topic.loading = false; topic.failed = true; setTopicStatus(topic, '分页解析失败,已保留原始顺序'); applyTopicView(topic, 'original'); logError(error); }); } else { setTopicStatus(topic, `已加载 ${topic.models.length} 条回复,关系为启发式重建`); applyTopicView(topic, runtime.settings.preferredView); } const activateThreadedReply = (event) => { if (!(event.target instanceof Element)) return; const action = event.target.closest('.wt-v2ex-native-reply-action'); if (!action || !topic.threadedView.contains(action)) return; if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); insertReplyPrefix(findEditor(), action.dataset.replyUser, Number(action.dataset.replyFloor)); }; topic.threadedView.addEventListener('click', activateThreadedReply); topic.threadedView.addEventListener('keydown', activateThreadedReply); } function removeConversationControl() { if (runtime.conversationPanel) runtime.conversationPanel.hidden = true; if (runtime.conversationToggle) { runtime.conversationToggle.setAttribute('aria-expanded', 'false'); runtime.conversationToggle.setAttribute('aria-label', '打开会话视图控制面板'); } runtime.conversationPanel?.remove(); runtime.conversationToggle?.remove(); runtime.conversationPanel = null; runtime.conversationToggle = null; } async function synchronizeTopicPage() { if (runtime.navigationSync) { scheduleTopicSynchronization(); return; } const context = topicContext(); const synchronizationRevision = runtime.navigationRevision; if (topicStateMatchesLocation(context)) { scanImageContent(document); return; } runtime.navigationSync = true; try { if (context && !await waitForTopicDom(context)) { if ( synchronizationRevision !== runtime.navigationRevision || topicContext()?.key !== context.key ) scheduleTopicSynchronization(); else { closeImageLightbox(); if (runtime.topic) { applyTopicView(runtime.topic, 'original'); runtime.topic.threadedView.remove(); runtime.topic = null; } removeConversationControl(); scanImageContent(document); } return; } if ( synchronizationRevision !== runtime.navigationRevision || context?.key !== topicContext()?.key ) { scheduleTopicSynchronization(); return; } closeImageLightbox(); if (runtime.topic) { applyTopicView(runtime.topic, 'original'); runtime.topic.threadedView.remove(); runtime.topic = null; } removeConversationControl(); if (context) { await initializeTopic(); if (runtime.topic) createConversationControl(runtime.topic); } scanImageContent(document); } finally { runtime.navigationSync = false; if (runtime.topic && !isCurrentTopic(runtime.topic)) scheduleTopicSynchronization(); } } function scheduleTopicSynchronization() { if (runtime.navigationSchedule) return; runtime.navigationSchedule = window.setTimeout(() => { runtime.navigationSchedule = 0; void synchronizeTopicPage().catch(logError); }, 0); } function handleTopicNavigation() { const nextTopicKey = topicContext()?.key || null; if (nextTopicKey !== runtime.observedTopicKey) { runtime.observedTopicKey = nextTopicKey; runtime.navigationRevision += 1; } scheduleTopicSynchronization(); } function observeNavigation() { if (runtime.navigationAttached) return; runtime.navigationAttached = true; runtime.observedTopicKey = topicContext()?.key || null; ['pushState', 'replaceState'].forEach((method) => { const original = window.history[method]; if (typeof original !== 'function') return; window.history[method] = function patchedHistoryMethod(...args) { const result = original.apply(this, args); window.dispatchEvent(new Event('wt-v2ex-navigation')); return result; }; }); window.addEventListener('popstate', handleTopicNavigation); window.addEventListener('wt-v2ex-navigation', handleTopicNavigation); } function findEditor() { const candidates = allMatches(document, SELECTORS.editor); return candidates.find(isSupportedEditor) || null; } function isSupportedEditor(editor) { if (!(editor instanceof HTMLTextAreaElement)) return false; const id = (editor.id || '').toLowerCase(); const name = (editor.name || '').toLowerCase(); if (!['reply_content', 'topic_content'].includes(id) && name !== 'content') return false; const form = editor.closest('form'); const action = form?.getAttribute('action') || ''; const nearbyEditor = editor.closest('#reply-box, #new-topic, #topic-editor, [id*="reply"], [id*="topic"]'); return id === 'reply_content' || id === 'topic_content' || /\/t\/\d+/.test(action) || /(?:^|\/)new(?:$|[/?#])/.test(action) || Boolean(nearbyEditor); } function insertAtSelection(editor, text) { if (!editor) return false; const start = editor.selectionStart ?? editor.value.length; const end = editor.selectionEnd ?? start; editor.focus(); if (typeof editor.setRangeText === 'function') { editor.setRangeText(text, start, end, 'end'); } else { editor.value = `${editor.value.slice(0, start)}${text}${editor.value.slice(end)}`; editor.selectionStart = editor.selectionEnd = start + text.length; } editor.dispatchEvent(new Event('input', { bubbles: true })); return true; } function insertReplyPrefix(editor, username, floor) { if (!editor || !username || !Number.isInteger(floor)) return; const escaped = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const prefixPattern = new RegExp(`^\\s*@${escaped}\\b(?:\\s+#${floor}\\b)?\\s*`, 'i'); if (prefixPattern.test(editor.value)) { if (!new RegExp(`^\\s*@${escaped}\\s+#${floor}\\b`, 'i').test(editor.value)) { editor.value = editor.value.replace(new RegExp(`^(\\s*@${escaped}\\b)`, 'i'), `$1 #${floor}`); editor.dispatchEvent(new Event('input', { bubbles: true })); } editor.focus(); return; } insertAtSelection(editor, `@${username} #${floor} `); } function enhanceNativeReplyAction(event) { if (!(event.target instanceof Element)) return; const action = event.target.closest(SELECTORS.nativeReplyAction.join(',')); if (!action) return; const reply = action.closest('.cell[id^="r_"]'); if (!reply) return; const author = firstMatch(reply, SELECTORS.replyAuthor)?.textContent.trim(); const floor = parseFloor(firstMatch(reply, SELECTORS.replyFloor)?.textContent); if (!author || !floor) return; window.setTimeout(() => { const editor = findEditor(); if (editor) insertReplyPrefix(editor, author, floor); }, 0); } function attachNativeReplyEnhancer() { if (runtime.nativeReplyListenerAttached) return; runtime.nativeReplyListenerAttached = true; document.addEventListener('click', enhanceNativeReplyAction, true); } function imageContentContainers(root = document) { const containers = []; const seen = new Set(); if (root instanceof Element && root.matches(IMAGE_CONTENT_SELECTOR)) { seen.add(root); containers.push(root); } if (typeof root.querySelectorAll === 'function') { root.querySelectorAll(IMAGE_CONTENT_SELECTOR).forEach((container) => { if (!seen.has(container)) { seen.add(container); containers.push(container); } }); } return containers; } function createInlineImage(url) { const image = document.createElement('img'); image.className = 'wt-v2ex-inline-image'; image.src = url; image.alt = '来自 ' + new URL(url).hostname + ' 的图片预览'; image.loading = 'lazy'; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; image.dataset.wtV2exImageUrl = url; return image; } function adjacentImageMatches(source, url) { const sibling = source.nextElementSibling; if (!(sibling instanceof HTMLImageElement)) return false; const siblingUrl = safeUrl(sibling.currentSrc || sibling.getAttribute('src'), 'image', sibling.baseURI); return siblingUrl === url; } function enhanceLinkedImages(container) { container.querySelectorAll('a[href]').forEach((anchor) => { if (anchor.dataset.wtV2exImageEnhanced === 'true') return; anchor.dataset.wtV2exImageEnhanced = 'true'; if (anchor.querySelector('img')) return; const url = imagePreviewUrl(anchor.getAttribute('href'), anchor.baseURI); if (!url || adjacentImageMatches(anchor, url)) return; anchor.insertAdjacentElement('afterend', createInlineImage(url)); }); } function enhanceTextImages(container) { if (container.dataset.wtV2exImageTextEnhanced === 'true') return; container.dataset.wtV2exImageTextEnhanced = 'true'; const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !/https?:\/\//i.test(node.nodeValue)) return NodeFilter.FILTER_REJECT; const parent = node.parentElement; if (!parent || parent.closest('a, code, pre, textarea, script, style, .wt-v2ex-image-lightbox')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); const textNodes = []; for (let node = walker.nextNode(); node; node = walker.nextNode()) textNodes.push(node); textNodes.forEach((textNode) => { let insertionPoint = textNode; imageUrlsInText(textNode.nodeValue, textNode.baseURI).forEach((url) => { const image = createInlineImage(url); insertionPoint.parentNode.insertBefore(image, insertionPoint.nextSibling); insertionPoint = image; }); }); } function prepareLightboxTarget(image) { if (!(image instanceof HTMLImageElement) || image.classList.contains('avatar') || image.closest('.wt-v2ex-image-lightbox')) return; if (!lightboxUrlForImage(image)) return; if (image.dataset.wtV2exLightboxReady === 'true') return; image.dataset.wtV2exLightboxReady = 'true'; image.classList.add('wt-v2ex-lightbox-target'); if (!image.hasAttribute('tabindex') || image.tabIndex < 0) { image.dataset.wtV2exOriginalTabindex = image.hasAttribute('tabindex') ? image.getAttribute('tabindex') : ''; image.tabIndex = 0; image.dataset.wtV2exManagedTabindex = 'true'; } if (!image.hasAttribute('role')) { image.setAttribute('role', 'button'); image.dataset.wtV2exAddedRole = 'true'; } if (!image.hasAttribute('aria-label')) { image.setAttribute('aria-label', image.alt ? '打开图片预览:' + image.alt : '打开图片预览'); image.dataset.wtV2exAddedAriaLabel = 'true'; } } function prepareLightboxTargets(container) { container.querySelectorAll('img').forEach(prepareLightboxTarget); } function processImageContent(container) { if (runtime.settings.imagePreviewEnabled) { enhanceLinkedImages(container); enhanceTextImages(container); } if (runtime.settings.imageLightboxEnabled) prepareLightboxTargets(container); } function scanImageContent(root = document) { if (!runtime.settings.imagePreviewEnabled && !runtime.settings.imageLightboxEnabled) return; imageContentContainers(root).forEach(processImageContent); } function resetImagePreviews(root = document) { if (root instanceof Element && root.matches('.wt-v2ex-inline-image')) root.remove(); if (typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('.wt-v2ex-inline-image').forEach((image) => image.remove()); root.querySelectorAll('[data-wt-v2ex-image-enhanced]').forEach((source) => { delete source.dataset.wtV2exImageEnhanced; }); root.querySelectorAll('[data-wt-v2ex-image-text-enhanced]').forEach((container) => { delete container.dataset.wtV2exImageTextEnhanced; }); } function resetLightboxTarget(image) { if (!(image instanceof HTMLImageElement)) return; image.classList.remove('wt-v2ex-lightbox-target'); if (image.dataset.wtV2exManagedTabindex === 'true') { if (image.dataset.wtV2exOriginalTabindex) image.setAttribute('tabindex', image.dataset.wtV2exOriginalTabindex); else image.removeAttribute('tabindex'); } if (image.dataset.wtV2exAddedRole === 'true') image.removeAttribute('role'); if (image.dataset.wtV2exAddedAriaLabel === 'true') image.removeAttribute('aria-label'); delete image.dataset.wtV2exLightboxReady; delete image.dataset.wtV2exManagedTabindex; delete image.dataset.wtV2exOriginalTabindex; delete image.dataset.wtV2exAddedRole; delete image.dataset.wtV2exAddedAriaLabel; } function resetLightboxTargets(root = document) { if (root instanceof HTMLImageElement && root.dataset.wtV2exLightboxReady === 'true') resetLightboxTarget(root); if (typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('img[data-wt-v2ex-lightbox-ready="true"]').forEach(resetLightboxTarget); } function lightboxUrlForImage(image) { if (!(image instanceof HTMLImageElement)) return null; const linkedImageUrl = imagePreviewUrl(image.closest('a[href]')?.getAttribute('href'), image.baseURI); return linkedImageUrl || safeUrl(image.currentSrc || image.getAttribute('src'), 'image', image.baseURI); } function ensureImageLightbox() { if (runtime.imageLightbox?.isConnected) return runtime.imageLightbox; const overlay = document.createElement('div'); overlay.className = 'wt-v2ex-image-lightbox'; overlay.hidden = true; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', '图片预览'); const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.className = 'wt-v2ex-image-lightbox-close'; closeButton.setAttribute('aria-label', '关闭图片预览'); closeButton.textContent = '×'; const image = document.createElement('img'); image.className = 'wt-v2ex-image-lightbox-image'; image.alt = '图片全屏预览'; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; const originalLink = document.createElement('a'); originalLink.className = 'wt-v2ex-image-lightbox-link'; originalLink.target = '_blank'; originalLink.rel = 'noopener noreferrer'; originalLink.textContent = '在新标签页打开原图'; overlay.append(closeButton, image, originalLink); overlay.addEventListener('click', (event) => { if (event.target === overlay) closeImageLightbox(); }); overlay.addEventListener('keydown', (event) => { if (event.key !== 'Tab') return; const focusable = [closeButton, originalLink].filter((element) => !element.hidden); if (!focusable.length) return; const currentIndex = focusable.indexOf(document.activeElement); const nextIndex = event.shiftKey ? (currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1) : (currentIndex < 0 || currentIndex === focusable.length - 1 ? 0 : currentIndex + 1); event.preventDefault(); focusable[nextIndex].focus(); }); closeButton.addEventListener('click', () => closeImageLightbox()); document.body.append(overlay); runtime.imageLightbox = overlay; runtime.imageLightboxImage = image; runtime.imageLightboxLink = originalLink; runtime.imageLightboxClose = closeButton; return overlay; } function openImageLightbox(opener) { if (!runtime.settings.imageLightboxEnabled || !(opener instanceof HTMLImageElement)) return false; if (opener.complete && opener.naturalWidth === 0) return false; const url = lightboxUrlForImage(opener); if (!url) return false; const overlay = ensureImageLightbox(); runtime.imageLightboxOpener = opener; runtime.imageLightboxImage.src = url; runtime.imageLightboxImage.alt = opener.alt || '图片全屏预览'; runtime.imageLightboxLink.href = url; if (!runtime.imageScrollLock) { runtime.imageScrollLock = { documentOverflow: document.documentElement.style.overflow, bodyOverflow: document.body.style.overflow, scrollX: window.scrollX, scrollY: window.scrollY }; document.documentElement.style.overflow = 'hidden'; document.body.style.overflow = 'hidden'; } overlay.hidden = false; if (runtime.imageLightboxFocusFrame) cancelAnimationFrame(runtime.imageLightboxFocusFrame); runtime.imageLightboxFocusFrame = requestAnimationFrame(() => { runtime.imageLightboxFocusFrame = 0; if (!overlay.hidden) runtime.imageLightboxClose?.focus({ preventScroll: true }); }); return true; } function closeImageLightbox(restoreFocus = true) { if (runtime.imageLightboxFocusFrame) { cancelAnimationFrame(runtime.imageLightboxFocusFrame); runtime.imageLightboxFocusFrame = 0; } const overlay = runtime.imageLightbox; if (!overlay || overlay.hidden) return; overlay.hidden = true; runtime.imageLightboxImage?.removeAttribute('src'); runtime.imageLightboxLink?.removeAttribute('href'); if (runtime.imageScrollLock) { const scrollLock = runtime.imageScrollLock; document.documentElement.style.overflow = scrollLock.documentOverflow; document.body.style.overflow = scrollLock.bodyOverflow; runtime.imageScrollLock = null; window.scrollTo(scrollLock.scrollX, scrollLock.scrollY); } const opener = runtime.imageLightboxOpener; runtime.imageLightboxOpener = null; if (restoreFocus && opener?.isConnected) opener.focus({ preventScroll: true }); } function contentImageFromEvent(event) { if (!(event.target instanceof HTMLImageElement)) return null; const image = event.target; if (!image.closest(IMAGE_CONTENT_SELECTOR) || image.classList.contains('avatar') || image.closest('.wt-v2ex-image-lightbox')) return null; return image; } function activateContentImage(event) { if (!runtime.settings.imageLightboxEnabled || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; if (event.type === 'click' && event.button !== 0) return; if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return; const image = contentImageFromEvent(event); if (!image || !openImageLightbox(image)) return; event.preventDefault(); } function handleImageKeydown(event) { if (event.key === 'Escape' && runtime.imageLightbox && !runtime.imageLightbox.hidden) { event.preventDefault(); closeImageLightbox(); return; } activateContentImage(event); } function handleImageLoadFailure(event) { if (!(event.target instanceof HTMLImageElement)) return; if (event.target === runtime.imageLightboxImage) { closeImageLightbox(); return; } if (event.target.classList.contains('wt-v2ex-inline-image')) event.target.remove(); } function initializeImageEnhancement() { if (!runtime.imageDelegationAttached) { runtime.imageDelegationAttached = true; document.addEventListener('click', activateContentImage); document.addEventListener('keydown', handleImageKeydown); document.addEventListener('error', handleImageLoadFailure, true); } scanImageContent(document); } function updateScrollButton() { if (!runtime.scrollButton) return; runtime.scrollButton.hidden = !runtime.settings.scrollTopEnabled || window.scrollY < SCROLL_THRESHOLD; } function initializeScrollToTop() { if (runtime.scrollButton) return; const dock = ensureControlDock(); const button = document.createElement('button'); button.type = 'button'; button.className = 'wt-v2ex-scroll-top'; button.textContent = '↑'; button.setAttribute('aria-label', '返回顶部'); button.title = '返回顶部'; button.addEventListener('click', () => { const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; window.scrollTo({ top: 0, behavior: reducedMotion ? 'auto' : 'smooth' }); }); dock.append(button); runtime.scrollButton = button; window.addEventListener('scroll', () => { if (runtime.scrollFrame) return; runtime.scrollFrame = requestAnimationFrame(() => { runtime.scrollFrame = 0; updateScrollButton(); }); }, { passive: true }); updateScrollButton(); } function addStyles() { const css = ` .wt-v2ex-control-dock { position:fixed; z-index:1000; right:max(16px, env(safe-area-inset-right)); bottom:max(16px, env(safe-area-inset-bottom)); display:flex; flex-direction:column; align-items:flex-end; gap:8px; } .wt-v2ex-conversation-toggle, .wt-v2ex-scroll-top { border:1px solid rgba(127,127,127,.4); background:var(--v2ex-background-color,#fff); color:inherit; box-shadow:0 3px 12px rgba(0,0,0,.16); cursor:pointer; } .wt-v2ex-conversation-toggle { min-width:42px; min-height:34px; border-radius:17px; padding:5px 10px; } .wt-v2ex-scroll-top { width:42px; height:42px; border-radius:50%; font-size:20px; line-height:1; } .wt-v2ex-conversation-panel { position:absolute; right:0; bottom:calc(100% + 8px); width:min(320px, calc(100vw - 24px)); max-height:min(70vh, 560px); overflow:auto; display:grid; gap:9px; padding:12px; border:1px solid rgba(127,127,127,.28); border-radius:7px; background:var(--v2ex-background-color,#fff); color:var(--v2ex-text-color,#333); box-shadow:0 5px 20px rgba(0,0,0,.2); } .wt-v2ex-conversation-panel[hidden] { display:none !important; } .wt-v2ex-conversation-panel-title { font-weight:600; } .wt-v2ex-conversation-panel-group { display:flex; flex-wrap:wrap; gap:6px; } .wt-v2ex-panel-action { border:1px solid rgba(127,127,127,.35); border-radius:4px; padding:5px 8px; background:transparent; color:inherit; cursor:pointer; font:inherit; } .wt-v2ex-panel-action:hover, .wt-v2ex-panel-action[aria-pressed="true"] { background:rgba(127,127,127,.12); } .wt-v2ex-thread-status { color:#666; font-size:12px; } .wt-v2ex-threaded-view { margin:10px 0; } #Main .cell[id^="r_"].wt-v2ex-native-hidden, #Wrapper .cell[id^="r_"].wt-v2ex-native-hidden { display:none !important; } .wt-v2ex-threaded-reply { position:relative; } .wt-v2ex-threaded-reply .wt-v2ex-reply-header { display:flex; flex-wrap:wrap; align-items:baseline; gap:0 7px; margin-bottom:5px; } .wt-v2ex-threaded-reply .wt-v2ex-native-reply-controls { float:none; order:999; display:inline-flex; flex:0 0 auto; align-items:center; gap:5px; margin-left:auto; white-space:nowrap; } .wt-v2ex-threaded-reply .wt-v2ex-native-reply-action:focus-visible { outline:2px solid #1677ff; outline-offset:2px; } .wt-v2ex-threaded-reply .wt-v2ex-native-reply-action:hover { opacity:.72; } .wt-v2ex-inline-image { display:block; max-width:100%; height:auto; margin:8px 0; cursor:zoom-in; } .wt-v2ex-lightbox-target { cursor:zoom-in; } .wt-v2ex-image-lightbox { position:fixed; z-index:2147483646; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; box-sizing:border-box; padding:24px; overflow:auto; background:rgba(0,0,0,.88); } .wt-v2ex-image-lightbox[hidden] { display:none !important; } .wt-v2ex-image-lightbox-image { display:block; max-width:92vw; max-height:90vh; width:auto; height:auto; object-fit:contain; } .wt-v2ex-image-lightbox-close { position:fixed; top:max(14px, env(safe-area-inset-top)); right:max(16px, env(safe-area-inset-right)); width:42px; height:42px; border:1px solid rgba(255,255,255,.55); border-radius:50%; background:rgba(0,0,0,.58); color:#fff; cursor:pointer; font:32px/1 sans-serif; } .wt-v2ex-image-lightbox-link { color:#fff; text-decoration:underline; } .wt-v2ex-image-lightbox-close:focus-visible, .wt-v2ex-image-lightbox-link:focus-visible, .wt-v2ex-lightbox-target:focus-visible { outline:2px solid #69a8ff; outline-offset:3px; } .wt-v2ex-relation { padding:1px 4px; border:1px solid rgba(127,127,127,.3); border-radius:3px; font-size:11px; } .wt-v2ex-relation-exact { color:#176b3a; } .wt-v2ex-relation-inferred { color:#805b00; } .wt-v2ex-relation-unresolved { color:#9b2c2c; } .wt-v2ex-thread-children { margin-top:6px; padding-left:10px; border-left:2px solid rgba(22,119,255,.28); } .wt-v2ex-child-depth-1 { margin-left:4px; } .wt-v2ex-child-depth-2 { margin-left:8px; } .wt-v2ex-child-depth-3 { margin-left:12px; } .wt-v2ex-child-depth-4 { margin-left:16px; } .wt-v2ex-child-depth-5 { margin-left:20px; } .wt-v2ex-child-depth-6 { margin-left:24px; } .wt-v2ex-child-depth-6 .wt-v2ex-child-depth-6 { margin-left:0; padding-left:0; border-left:0; } .wt-v2ex-panel-action:focus-visible, .wt-v2ex-conversation-toggle:focus-visible, .wt-v2ex-scroll-top:focus-visible { outline:2px solid #1677ff; outline-offset:2px; } @media (max-width:600px) { .wt-v2ex-control-dock { right:max(10px, env(safe-area-inset-right)); bottom:max(10px, env(safe-area-inset-bottom)); } .wt-v2ex-conversation-panel { max-height:60vh; } .wt-v2ex-threaded-view { width:calc(100vw - 40px); max-width:calc(100vw - 40px); overflow-x:hidden; } .wt-v2ex-threaded-reply { box-sizing:border-box; max-width:100%; min-width:0; overflow:hidden; } .wt-v2ex-threaded-reply table { width:100%; max-width:100%; table-layout:fixed; } .wt-v2ex-threaded-reply .wt-v2ex-reply-header { min-width:0; } .wt-v2ex-threaded-reply .wt-v2ex-native-reply-controls { max-width:100%; } } @media (prefers-color-scheme:dark) { .wt-v2ex-conversation-panel, .wt-v2ex-conversation-toggle, .wt-v2ex-scroll-top { background:#202124; color:#e7e7e7; border-color:rgba(255,255,255,.2); } .wt-v2ex-thread-status { color:#aaa; } } html.night .wt-v2ex-conversation-panel, html.night .wt-v2ex-conversation-toggle, html.night .wt-v2ex-scroll-top, body.night .wt-v2ex-conversation-panel, body.night .wt-v2ex-conversation-toggle, body.night .wt-v2ex-scroll-top, [data-theme="dark"] .wt-v2ex-conversation-panel, [data-theme="dark"] .wt-v2ex-conversation-toggle, [data-theme="dark"] .wt-v2ex-scroll-top { background:#202124; color:#e7e7e7; border-color:rgba(255,255,255,.2); } @media (prefers-reduced-motion:reduce) { .wt-v2ex-scroll-top { scroll-behavior:auto; } } `; if (typeof GM_addStyle === 'function') GM_addStyle(css); else { const style = document.createElement('style'); style.textContent = css; document.head.append(style); } } function claimInitialization() { if (document.documentElement.dataset.wtV2exInitialized === 'true') return false; document.documentElement.dataset.wtV2exInitialized = 'true'; return true; } async function initialize() { if (!claimInitialization()) return; addStyles(); registerMenuCommands(); initializeImageEnhancement(); attachNativeReplyEnhancer(); observeNavigation(); try { await initializeTopic(); if (runtime.topic) createConversationControl(runtime.topic); } catch (error) { if (runtime.topic) { runtime.topic.failed = true; applyTopicView(runtime.topic, 'original'); createConversationControl(runtime.topic); } logError(error); } try { if (runtime.settings.scrollTopEnabled) initializeScrollToTop(); } catch (error) { logError(error); } } initialize(); })();