// ==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();
})();