// ==UserScript== // @name Discourse Topic Quick Switcher // @name:zh-CN Discourse 话题快捷切换器 // @namespace https://github.com/utags // @homepageURL https://github.com/utags/userscripts#readme // @supportURL https://github.com/utags/userscripts/issues // @version 0.3.2 // @description Enhance Discourse forums with instant topic switching, current topic highlighting, and quick navigation to previous/next topics // @description:zh-CN 增强 Discourse 论坛体验,提供即时话题切换、当前话题高亮和上一个/下一个话题的快速导航功能 // @author Pipecraft // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=meta.discourse.org // @match https://meta.discourse.org/* // @match https://linux.do/* // @match https://idcflare.com/* // @match https://meta.appinn.net/* // @match https://community.openai.com/* // @match https://community.cloudflare.com/* // @match https://community.wanikani.com/* // @match https://forum.cursor.com/* // @match https://forum.obsidian.md/* // @match https://forum-zh.obsidian.md/* // @noframes // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @downloadURL https://update.greasyfork.icu/scripts/550982/Discourse%20Topic%20Quick%20Switcher.user.js // @updateURL https://update.greasyfork.icu/scripts/550982/Discourse%20Topic%20Quick%20Switcher.meta.js // ==/UserScript== ;(function () { 'use strict' // Configuration const CONFIG = { // Hotkey (default is backtick key `) HOTKEY: '`', // Cache key base name CACHE_KEY_BASE: 'discourse_topic_list_cache', // Cache expiry time (milliseconds) - 1 hour CACHE_EXPIRY: 60 * 60 * 1000, // Whether to show floating button on topic pages SHOW_FLOATING_BUTTON: true, // Route check interval (milliseconds) ROUTE_CHECK_INTERVAL: 500, // Whether to automatically follow system dark mode AUTO_DARK_MODE: true, // Default language (en or zh-CN) DEFAULT_LANGUAGE: 'en', // Settings storage key SETTINGS_KEY: 'discourse_topic_switcher_settings', } // User settings with defaults let userSettings = { language: CONFIG.DEFAULT_LANGUAGE, showNavigationButtons: true, } /** * Get site-specific cache key * @returns {string} The cache key for the current site */ function getSiteCacheKey() { // Get current hostname const hostname = window.location.hostname // Create a site-specific cache key return `${CONFIG.CACHE_KEY_BASE}_${hostname}` } // Internationalization support const I18N = { en: { viewTopicList: 'View topic list (press ` key)', topicList: 'Topic List', cacheExpired: 'Cache expired', cachedAgo: 'Cached {time} ago', searchPlaceholder: 'Search topics...', noResults: 'No matching topics found', backToList: 'Back to list', topicsCount: '{count} topics', currentTopic: 'Current topic', sourceFrom: 'Source', close: 'Close', loading: 'Loading...', refresh: 'Refresh', replies: 'Replies', views: 'Views', activity: 'Activity', language: 'Language', noCachedList: 'No cached topic list available. Please visit a topic list page first.', prevTopic: 'Previous Topic', nextTopic: 'Next Topic', noPrevTopic: 'No previous topic', noNextTopic: 'No next topic', settings: 'Settings', save: 'Save', cancel: 'Cancel', showNavigationButtons: 'Show navigation buttons', }, 'zh-CN': { viewTopicList: '查看话题列表(按 ` 键)', topicList: '话题列表', cacheExpired: '缓存已过期', cachedAgo: '{time}前缓存', searchPlaceholder: '搜索话题...', noResults: '未找到匹配的话题', backToList: '返回列表', topicsCount: '{count}个话题', currentTopic: '当前话题', sourceFrom: '来源', close: '关闭', loading: '加载中...', refresh: '刷新', replies: '回复', views: '浏览', activity: '活动', language: '语言', noCachedList: '没有可用的话题列表缓存。请先访问一个话题列表页面。', prevTopic: '上一个话题', nextTopic: '下一个话题', noPrevTopic: '没有上一个话题', noNextTopic: '没有下一个话题', settings: '设置', save: '保存', cancel: '取消', showNavigationButtons: '显示导航按钮', }, } /** * Load user settings from storage */ function loadUserSettings() { const savedSettings = GM_getValue(CONFIG.SETTINGS_KEY) if (savedSettings) { try { const parsedSettings = JSON.parse(savedSettings) userSettings = { ...userSettings, ...parsedSettings } } catch (e) { console.error('[DTQS] Error parsing saved settings:', e) } } return userSettings } /** * Save user settings to storage */ function saveUserSettings() { GM_setValue(CONFIG.SETTINGS_KEY, JSON.stringify(userSettings)) } // Get user language function getUserLanguage() { // Use language from settings if ( userSettings.language && (userSettings.language === 'en' || userSettings.language === 'zh-CN') ) { return userSettings.language } // Try to get language from browser const browserLang = navigator.language || navigator.userLanguage // Check if we support this language if (browserLang.startsWith('zh')) { return 'zh-CN' } // Default to English return CONFIG.DEFAULT_LANGUAGE } // Current language let currentLanguage = getUserLanguage() /** * Create and show settings dialog */ function showSettingsDialog() { // If dialog already exists, don't create another one if (document.getElementById('dtqs-settings-overlay')) { return } // Create overlay const overlay = document.createElement('div') overlay.id = 'dtqs-settings-overlay' // Create dialog const dialog = document.createElement('div') dialog.id = 'dtqs-settings-dialog' // Create dialog content dialog.innerHTML = `

${t('settings')}

` // Add dialog to overlay overlay.appendChild(dialog) // Add overlay to page document.body.appendChild(overlay) // Add event listeners document .getElementById('dtqs-settings-save') .addEventListener('click', () => { // Save language setting const languageSelect = document.getElementById('dtqs-language-select') userSettings.language = languageSelect.value // Save navigation buttons setting const showNavButtons = document.getElementById('dtqs-show-nav-buttons') userSettings.showNavigationButtons = showNavButtons.checked // Save settings saveUserSettings() // Update language currentLanguage = userSettings.language // Close dialog closeSettingsDialog() // Remove and recreate floating button to apply new settings if (floatingButton) { hideFloatingButton() addFloatingButton() } // If topic list is open, reopen it to apply new settings if (topicListContainer) { hideTopicList() topicListContainer.remove() topicListContainer = null setTimeout(() => { showTopicList() }, 350) } }) document .getElementById('dtqs-settings-cancel') .addEventListener('click', closeSettingsDialog) // Close when clicking on overlay (outside dialog) overlay.addEventListener('click', (e) => { if (e.target === overlay) { closeSettingsDialog() } }) } /** * Close settings dialog */ function closeSettingsDialog() { const overlay = document.getElementById('dtqs-settings-overlay') if (overlay) { overlay.remove() } } // Translate function function t(key, params = {}) { // Get the translation let text = I18N[currentLanguage][key] || I18N['en'][key] || key // Replace parameters for (const param in params) { text = text.replace(`{${param}}`, params[param]) } return text } // Status variables let isListVisible = false let cachedTopicList = null let cachedTopicListTimestamp = 0 let cachedTopicListUrl = '' let cachedTopicListTitle = '' let floatingButton = null let topicListContainer = null let lastUrl = window.location.href let urlCheckTimer = null let isDarkMode = false let isButtonClickable = true // Flag to prevent consecutive clicks let prevTopic = null // Previous topic data let nextTopic = null // Next topic data /** * Detect dark mode */ function detectDarkMode() { // Check system dark mode if (CONFIG.AUTO_DARK_MODE && window.matchMedia) { // Check system preference const systemDarkMode = window.matchMedia( '(prefers-color-scheme: dark)' ).matches // Check if the Discourse site is in dark mode const discourseBodyClass = document.body.classList.contains('dark-scheme') || document.documentElement.classList.contains('dark-scheme') || document.body.dataset.colorScheme === 'dark' || document.documentElement.dataset.colorScheme === 'dark' || document.documentElement.dataset.themeType === 'dark' || // linux.do document.querySelector('header picture > source')?.media === 'all' // Enable dark mode if the system or site uses it isDarkMode = systemDarkMode || discourseBodyClass console.log( `[DTQS] Dark mode detection - System: ${systemDarkMode}, Site: ${discourseBodyClass}, Final: ${isDarkMode}` ) // Add or remove dark mode class if (isDarkMode) { document.body.classList.add('topic-list-viewer-dark-mode') } else { document.body.classList.remove('topic-list-viewer-dark-mode') } } } /** * Set up dark mode listener */ function setupDarkModeListener() { if (CONFIG.AUTO_DARK_MODE && window.matchMedia) { // Listen for system dark mode changes const darkModeMediaQuery = window.matchMedia( '(prefers-color-scheme: dark)' ) // Add change listener if (darkModeMediaQuery.addEventListener) { darkModeMediaQuery.addEventListener('change', (e) => { detectDarkMode() }) } else if (darkModeMediaQuery.addListener) { // Fallback for older browsers darkModeMediaQuery.addListener((e) => { detectDarkMode() }) } // Listen for Discourse theme changes const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if ( mutation.attributeName === 'class' || mutation.attributeName === 'data-color-scheme' ) { detectDarkMode() } }) }) // Observe class changes on body and html elements observer.observe(document.body, { attributes: true }) observer.observe(document.documentElement, { attributes: true }) } } /** * Initialize the script */ function init() { // Load user settings loadUserSettings() // Load cached topic list from storage loadCachedTopicList() // Detect dark mode detectDarkMode() // Set up dark mode listener // setupDarkModeListener() // Initial handling of the current page handleCurrentPage() // Set up URL change detection setupUrlChangeDetection() // Add global hotkey listener addHotkeyListener() } /** * Set up URL change detection * Use multiple methods to reliably detect URL changes */ function setupUrlChangeDetection() { // Record initial URL lastUrl = window.location.href // Method 1: Listen for popstate events (handles browser back/forward buttons) window.addEventListener('popstate', () => { console.log('[DTQS] Detected popstate event') handleCurrentPage() }) // Method 2: Use MutationObserver to listen for DOM changes that might indicate a URL change const pageObserver = new MutationObserver(() => { checkUrlChange('MutationObserver') }) // Start observing DOM changes pageObserver.observe(document.body, { childList: true, subtree: true, }) // Method 3: Set up a regular check as a fallback if (urlCheckTimer) { clearInterval(urlCheckTimer) } urlCheckTimer = setInterval(() => { checkUrlChange('Interval check') }, CONFIG.ROUTE_CHECK_INTERVAL) } /** * Check if the URL has changed * @param {string} source The source that triggered the check */ function checkUrlChange(source) { const currentUrl = window.location.href if (currentUrl !== lastUrl) { console.log(`[DTQS] URL change detected (Source: ${source})`, currentUrl) lastUrl = currentUrl handleCurrentPage() } } /** * Handle the current page */ function handleCurrentPage() { // If the list is visible, hide it if (isListVisible) { hideTopicList() } // Perform different actions based on the current page type if (isTopicPage()) { // On a topic page, add the floating button console.log('[DTQS] On a topic page, show button') if (CONFIG.SHOW_FLOATING_BUTTON) { addFloatingButton() // Update navigation buttons if we're on a topic page updateNavigationButtons() } // On a topic page, pre-render the list (if cached) if (cachedTopicList && !topicListContainer) { // Use setTimeout to ensure the DOM is fully loaded setTimeout(() => { prerenderTopicList() }, 100) } } else if (isTopicListPage()) { // On a topic list page, cache the current list console.log('[DTQS] On a list page, update cache') cacheCurrentTopicList() // Hide the button on the list page hideFloatingButton() } else { // On other pages, hide the button hideFloatingButton() // Observe the topic list element observeTopicListElement() } } /** * Check if the current page is a topic list page * @returns {boolean} Whether it is a topic list page */ function isTopicListPage() { return ( document.querySelector( '.contents table.topic-list tbody.topic-list-body' ) !== null ) } /** * Observe the appearance of the topic list element * Solves the problem that the list element may not be rendered when the page loads */ function observeTopicListElement() { // Create an observer instance const observer = new MutationObserver((mutations, obs) => { // Check if the list element has appeared if ( document.querySelector( '.contents table.topic-list tbody.topic-list-body' ) ) { console.log('[DTQS] Detected that the list element has been rendered') // If the list element appears, re-handle the current page handleCurrentPage() // The list element has been found, stop observing obs.disconnect() } }) // Configure observer options const config = { childList: true, // Observe changes to the target's child nodes subtree: true, // Observe all descendant nodes } // Start observing the document body observer.observe(document.body, config) // Set a timeout to avoid indefinite observation setTimeout(() => { observer.disconnect() }, 10000) // Stop observing after 10 seconds } /** * Check if the current page is a topic page * @returns {boolean} Whether it is a topic page */ function isTopicPage() { return window.location.pathname.includes('/t/') } /** * Check for URL changes */ function checkForUrlChanges() { const currentUrl = window.location.href if (currentUrl !== lastUrl) { lastUrl = currentUrl // If we're on a topic page, update the button if (isTopicPage()) { addFloatingButton() // Update navigation buttons with new adjacent topics updateNavigationButtons() } else { // Remove the button if not on a topic page if (floatingButton) { floatingButton.remove() floatingButton = null } } // Hide the topic list if it's visible if (isListVisible) { hideTopicList() } } } /** * Get the current topic ID * @returns {number|null} The current topic ID or null */ function getCurrentTopicId() { // Extract topic ID from the URL const match = window.location.pathname.match(/\/t\/[^\/]+\/(\d+)/) return match ? parseInt(match[1]) : null } /** * Check if a topic row is visible (not hidden) * @param {Element} row - The topic row element * @returns {boolean} - Whether the topic is visible */ function isTopicVisible(row) { // Use more reliable method to detect element visibility if (typeof row.checkVisibility === 'function') { return row.checkVisibility() } // If checkVisibility is not available, use offsetParent for detection return row.offsetParent !== null } /** * Find adjacent topics (previous and next) from the cached topic list * @returns {Object} Object containing previous and next topics */ function findAdjacentTopics() { // If no cached topic list, return empty result if (!cachedTopicList) { return { prev: null, next: null } } // Get current topic ID const currentId = getCurrentTopicId() if (!currentId) { return { prev: null, next: null } } // Create a temporary container to parse the cached HTML const tempContainer = document.createElement('div') tempContainer.style.position = 'absolute' tempContainer.style.visibility = 'hidden' tempContainer.innerHTML = `${cachedTopicList}
` // Add to document.body to ensure offsetParent works correctly document.body.appendChild(tempContainer) // Get all topic rows const topicRows = tempContainer.querySelectorAll('tr') if (!topicRows.length) { return { prev: null, next: null } } // Find the current topic index let currentIndex = -1 for (let i = 0; i < topicRows.length; i++) { const row = topicRows[i] const topicLink = row.querySelector('a.title') if (!topicLink) continue // Extract topic ID from the link const match = topicLink.href.match(/\/t\/[^\/]+\/(\d+)/) if (match && parseInt(match[1]) === currentId) { currentIndex = i break } } // If current topic not found in the list if (currentIndex === -1) { return { prev: null, next: null } } // Get previous visible topic let prevTopic = null for (let i = currentIndex - 1; i >= 0; i--) { const prevRow = topicRows[i] if (!isTopicVisible(prevRow)) continue const prevLink = prevRow.querySelector('a.title') if (prevLink) { prevTopic = { id: extractTopicId(prevLink.href), title: prevLink.textContent.trim(), url: prevLink.href, } break } } // Get next visible topic let nextTopic = null for (let i = currentIndex + 1; i < topicRows.length; i++) { const nextRow = topicRows[i] if (!isTopicVisible(nextRow)) continue const nextLink = nextRow.querySelector('a.title') if (nextLink) { nextTopic = { id: extractTopicId(nextLink.href), title: nextLink.textContent.trim(), url: nextLink.href, } break } } // Remove the temporary container from document.body tempContainer.remove() return { prev: prevTopic, next: nextTopic } } /** * Update navigation buttons with adjacent topics */ function updateNavigationButtons() { // Find adjacent topics const { prev, next } = findAdjacentTopics() console.log('[DTQS] Adjacent topics:', prev, next) // Store for global access prevTopic = prev nextTopic = next // Update previous topic button const prevButton = document.querySelector('.topic-nav-button.prev-topic') if (prevButton) { const titleSpan = prevButton.querySelector('.topic-nav-title') if (prev) { titleSpan.textContent = prev.title prevButton.title = prev.title prevButton.style.opacity = '1' prevButton.style.pointerEvents = 'auto' } else { titleSpan.textContent = '' prevButton.title = t('noPrevTopic') prevButton.style.opacity = '0.5' prevButton.style.pointerEvents = 'none' } } // Update next topic button const nextButton = document.querySelector('.topic-nav-button.next-topic') if (nextButton) { const titleSpan = nextButton.querySelector('.topic-nav-title') if (next) { titleSpan.textContent = next.title nextButton.title = next.title nextButton.style.opacity = '1' nextButton.style.pointerEvents = 'auto' } else { titleSpan.textContent = '' nextButton.title = t('noNextTopic') nextButton.style.opacity = '0.5' nextButton.style.pointerEvents = 'none' } } } /** * Navigate to previous topic */ function navigateToPrevTopic() { if (prevTopic && prevTopic.url) { navigateWithSPA(prevTopic.url) } } /** * Navigate to next topic */ function navigateToNextTopic() { if (nextTopic && nextTopic.url) { navigateWithSPA(nextTopic.url) } } /** * Extract topic ID from a topic URL * @param {string} url The topic URL * @returns {number|null} The topic ID or null */ function extractTopicId(url) { const match = url.match(/\/t\/[^\/]+\/(\d+)/) return match ? parseInt(match[1]) : null } /** * Cache the current topic list */ function cacheCurrentTopicList() { // Check if the list element exists const topicListBody = document.querySelector('tbody.topic-list-body') if (topicListBody) { // If the list element exists, process it directly updateTopicListCache(topicListBody) // Listen for list content changes (when scrolling to load more) observeTopicListChanges(topicListBody) } else { // If the list element does not exist, listen for its appearance console.log('[DTQS] Waiting for the topic list element to appear') observeTopicListAppearance() } } /** * Observe the appearance of the topic list element */ function observeTopicListAppearance() { // Create an observer instance const observer = new MutationObserver((mutations, obs) => { // Check if the list element has appeared const topicListBody = document.querySelector('tbody.topic-list-body') if (topicListBody) { console.log('[DTQS] Detected that the list element has been rendered') // Process the list content processTopicList(topicListBody) // Listen for list content changes observeTopicListChanges(topicListBody) // The list element has been found, stop observing obs.disconnect() } }) // Configure observer options const config = { childList: true, // Observe changes to the target's child nodes subtree: true, // Observe all descendant nodes } // Start observing the document body observer.observe(document.body, config) } /** * Observe topic list content changes (when scrolling to load more) * @param {Element} topicListBody The topic list element */ function observeTopicListChanges(topicListBody) { // Record the current number of rows let previousRowCount = topicListBody.querySelectorAll('tr').length // Create an observer instance const observer = new MutationObserver((mutations) => { // Get the current number of rows const currentRowCount = topicListBody.querySelectorAll('tr').length // If the number of rows increases, it means more topics have been loaded if (currentRowCount > previousRowCount) { console.log( `[DTQS] Detected list update, rows increased from ${previousRowCount} to ${currentRowCount}` ) // Update the cache updateTopicListCache(topicListBody) // Update the row count record previousRowCount = currentRowCount } }) // Configure observer options const config = { childList: true, // Observe changes to the target's child nodes subtree: true, // Observe all descendant nodes } // Start observing the list element observer.observe(topicListBody, config) } /** * Update the topic list cache * @param {Element} topicListBody The topic list element */ function updateTopicListCache(topicListBody) { // Ensure the list has content const topicRows = topicListBody.querySelectorAll('tr') if (topicRows.length === 0) { console.log('[DTQS] Topic list is empty, not caching') return } console.log('[DTQS] Updating topic list cache') // Clone the node to save the complete topic list const clonedTopicList = topicListBody.cloneNode(true) // Save the current URL to show the source when the list is popped up const currentUrl = window.location.href // Get the list title let listTitle = t('topicList') // const titleElement = document.querySelector( // '.category-name, .page-title h1, .topic-list-heading h2' // ) // if (titleElement) { // listTitle = titleElement.textContent.trim() // } const title = document.title.replace(/ - .*/, '').trim() if (title) { listTitle = title } // Get current category information (if any) let categoryInfo = '' const categoryBadge = document.querySelector( '.category-name .badge-category' ) if (categoryBadge) { categoryInfo = categoryBadge.textContent.trim() } console.log( `[DTQS] Caching topic list "${listTitle}", containing ${topicRows.length} topics` ) // Save to cache cachedTopicList = clonedTopicList.outerHTML cachedTopicListTimestamp = Date.now() cachedTopicListUrl = currentUrl cachedTopicListTitle = listTitle // Save to GM storage with site-specific key GM_setValue(getSiteCacheKey(), { html: cachedTopicList, timestamp: cachedTopicListTimestamp, url: cachedTopicListUrl, title: cachedTopicListTitle, category: categoryInfo, topicCount: topicRows.length, }) // Remove the list container, it needs to be re-rendered if (topicListContainer) { topicListContainer.remove() topicListContainer = null } } /** * Load the cached topic list from storage */ function loadCachedTopicList() { const cache = GM_getValue(getSiteCacheKey()) if (cache) { cachedTopicList = cache.html cachedTopicListTimestamp = cache.timestamp cachedTopicListUrl = cache.url cachedTopicListTitle = cache.title } } /** * Add a floating button */ function addFloatingButton() { // If the button already exists, do not add it again if (document.getElementById('topic-list-viewer-button')) return // Create the button container floatingButton = document.createElement('div') floatingButton.id = 'topic-list-viewer-button' // Create navigation container const navContainer = document.createElement('div') navContainer.className = 'topic-nav-container' // Control navigation buttons visibility based on user settings if (!userSettings.showNavigationButtons) { navContainer.classList.add('hide-nav-buttons') } // Create previous topic button const prevButton = document.createElement('div') prevButton.className = 'topic-nav-button prev-topic' prevButton.innerHTML = ` ` prevButton.title = t('prevTopic') prevButton.addEventListener('click', navigateToPrevTopic) // Create center button const centerButton = document.createElement('div') centerButton.className = 'topic-nav-button center-button' centerButton.innerHTML = ` ` centerButton.title = t('viewTopicList') centerButton.addEventListener('click', toggleTopicList) // Create next topic button const nextButton = document.createElement('div') nextButton.className = 'topic-nav-button next-topic' nextButton.innerHTML = ` ` nextButton.title = t('nextTopic') nextButton.addEventListener('click', navigateToNextTopic) // Add all elements to the container navContainer.appendChild(prevButton) navContainer.appendChild(centerButton) navContainer.appendChild(nextButton) floatingButton.appendChild(navContainer) // Add to page document.body.appendChild(floatingButton) // Update navigation buttons updateNavigationButtons() } /** * Add a hotkey listener */ function addHotkeyListener() { document.addEventListener('keydown', function (event) { // Check if the configured hotkey is pressed if (event.key === CONFIG.HOTKEY) { // Prevent default behavior and event bubbling event.preventDefault() event.stopPropagation() // Toggle topic list display toggleTopicList() } // If the list is visible, close it with the ESC key if (isListVisible && event.key === 'Escape') { hideTopicList() } }) } /** * Hide the floating button */ function hideFloatingButton() { if (floatingButton && floatingButton.parentNode) { floatingButton.parentNode.removeChild(floatingButton) floatingButton = null } } /** * Toggle the display state of the topic list * Includes debounce logic to prevent rapid consecutive clicks */ function toggleTopicList() { // If button is not clickable, return immediately if (!isButtonClickable) { return } // Set button to non-clickable state isButtonClickable = false // Execute the original toggle logic if (isListVisible) { hideTopicList() } else { showTopicList() } // Set a timeout to restore button clickable state after 800ms setTimeout(() => { isButtonClickable = true }, 800) } /** * Navigate to the specified URL using SPA routing * @param {string} url The target URL */ function navigateWithSPA(url) { // Hide the topic list hideTopicList() // Try to use pushState for SPA navigation try { console.log(`[DTQS] Navigating to ${url} using SPA routing`) // Use history API for navigation const urlObj = new URL(url) const pathname = urlObj.pathname // Update history history.pushState({}, '', pathname) // Trigger popstate event so Discourse can handle the route change window.dispatchEvent(new Event('popstate')) // Handle the current page setTimeout(handleCurrentPage, 100) } catch (error) { // If SPA navigation fails, fall back to normal navigation console.log( `[DTQS] SPA navigation failed, falling back to normal navigation to ${url}`, error ) window.location.href = url } } /** * Pre-render the topic list */ function prerenderTopicList() { // Record start time const startTime = performance.now() // If there is no cached topic list, do not pre-render if (!cachedTopicList) { console.log('[DTQS] No cached topic list available, cannot pre-render') return } // If the container already exists, do not create it again if (topicListContainer) { return } console.log('[DTQS] Pre-rendering topic list') // Check if the cache is expired const now = Date.now() const cacheAge = now - cachedTopicListTimestamp let cacheStatus = '' if (cacheAge > CONFIG.CACHE_EXPIRY) { cacheStatus = `
${t('cacheExpired')} (${formatTimeAgo(cacheAge)})
` } else { cacheStatus = `
${t('cachedAgo', { time: formatTimeAgo(cacheAge) })}
` } // Create the main container topicListContainer = document.createElement('div') topicListContainer.id = 'topic-list-viewer-container' // Create the overlay const overlay = document.createElement('div') overlay.className = 'topic-list-viewer-overlay' // Add an event listener to close the list when clicking the overlay overlay.addEventListener('click', (event) => { // If button is not clickable, return immediately if (!isButtonClickable) { return } // Make sure the click is on the overlay itself, not its children if (event.target === overlay) { hideTopicList() } }) // Create the content container const contentContainer = document.createElement('div') contentContainer.className = 'topic-list-viewer-wrapper' // Add the content container to the main container topicListContainer.appendChild(overlay) topicListContainer.appendChild(contentContainer) // Add to body document.body.appendChild(topicListContainer) // Try to get the position and width of the #main-outlet element const mainOutlet = document.getElementById('main-outlet') if (mainOutlet) { console.log( '[DTQS] Adjusting list container position and width to match #main-outlet' ) // Adjust position and width when the container is displayed const adjustContainerPosition = () => { if (topicListContainer && topicListContainer.style.display === 'flex') { const mainOutletRect = mainOutlet.getBoundingClientRect() // Set the position and width of the content container to match #main-outlet contentContainer.style.width = `${mainOutletRect.width}px` contentContainer.style.maxWidth = `${mainOutletRect.width}px` contentContainer.style.marginLeft = 'auto' contentContainer.style.marginRight = 'auto' } } // Add a listener to adjust the position const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if ( mutation.attributeName === 'style' && topicListContainer && topicListContainer.style.display === 'flex' ) { adjustContainerPosition() } }) }) observer.observe(topicListContainer, { attributes: true }) // Readjust on window resize window.addEventListener('resize', adjustContainerPosition) } else { console.log('[DTQS] #main-outlet does not exist, using default styles') } // Get the cached title const listTitle = cachedTopicListTitle || 'Topic List' // Fill the content container contentContainer.innerHTML = `

${listTitle}

${t('sourceFrom')}
${cacheStatus}
${cachedTopicList}
${t('topicList')} ${t('replies')} ${t('views')} ${t('activity')}
` // Add close button event contentContainer .querySelector('#topic-list-viewer-close') .addEventListener('click', hideTopicList) // Add settings button event contentContainer .querySelector('#topic-list-viewer-settings') .addEventListener('click', showSettingsDialog) // Add SPA routing events to all links in the topic list const topicLinks = contentContainer.querySelectorAll('.topic-list-item a') topicLinks.forEach((link) => { link.addEventListener( 'click', function (event) { console.log(`[DTQS] Link clicked ${link.href}`) event.preventDefault() event.stopPropagation() navigateWithSPA(link.href, null) return false }, true ) }) // Initially hidden topicListContainer.style.display = 'none' topicListContainer.classList.remove('visible') // Calculate and print execution time const endTime = performance.now() console.log( `[DTQS] Pre-rendering topic list completed in ${(endTime - startTime).toFixed(2)}ms` ) } /** * Show the topic list */ function showTopicList() { // Record start time const startTime = performance.now() // If there is no cached topic list, do not show if (!cachedTopicList) { alert(t('noCachedList')) return } // If the container does not exist, pre-render it first if (!topicListContainer) { prerenderTopicList() } // Hide body and html scrollbars document.body.style.overflow = 'hidden' document.documentElement.style.overflow = 'hidden' // Record the current scroll position for restoration window._savedScrollPosition = window.scrollY || document.documentElement.scrollTop // Show the container and add the visible class immediately topicListContainer.style.display = 'flex' // Force reflow // void topicListContainer.offsetWidth topicListContainer.classList.add('visible') isListVisible = true // Highlight the current topic const currentTopicId = getCurrentTopicId() // First, remove any existing highlights const previousHighlightedRows = topicListContainer.querySelectorAll( '.topic-list-item.current-topic' ) previousHighlightedRows.forEach((row) => { row.classList.remove('current-topic') }) if (currentTopicId) { // Find all topic rows const topicRows = topicListContainer.querySelectorAll('.topic-list-item') topicRows.forEach((row) => { // Get the topic link const topicLink = row.querySelector('a.title') if (topicLink) { // Extract the topic ID from the link const match = topicLink.href.match(/\/t\/[^\/]+\/(\d+)/) if (match && parseInt(match[1]) === currentTopicId) { // Add highlight class row.classList.add('current-topic') // Scroll to the current topic setTimeout(() => { row.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 300) } } }) } // Calculate and print execution time const endTime = performance.now() console.log( `[DTQS] Showing topic list completed in ${(endTime - startTime).toFixed(2)}ms` ) } /** * Hide the topic list */ function hideTopicList() { if (!topicListContainer) return // Restore body and html scrollbars document.body.style.overflow = '' document.documentElement.style.overflow = '' // Restore scroll position if (window._savedScrollPosition !== undefined) { window.scrollTo(0, window._savedScrollPosition) window._savedScrollPosition = undefined } // Remove the visible class to trigger the fade-out animation topicListContainer.classList.remove('visible') // Hide after the animation is complete setTimeout(() => { if (topicListContainer) { topicListContainer.style.display = 'none' } isListVisible = false }, 300) } /** * Format time difference * @param {number} ms - Milliseconds * @returns {string} - Formatted time difference */ function formatTimeAgo(ms) { const seconds = Math.floor(ms / 1000) const minutes = Math.floor(seconds / 60) const hours = Math.floor(minutes / 60) const days = Math.floor(hours / 24) if (days > 0) return `${days}d` if (hours > 0) return `${hours}h` if (minutes > 0) return `${minutes}m` return `${seconds}s` } // Add styles GM_addStyle(` #topic-list-viewer-button { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); border-radius: 20px; background-color: #0078d7; color: white; border: none; box-shadow: 0 2px 5px rgba(0,0,0,0.2); cursor: pointer; z-index: 99999; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; padding: 5px 10px; user-select: none; } /* Settings Dialog Styles */ #dtqs-settings-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 999999; display: flex; justify-content: center; align-items: center; } #dtqs-settings-dialog { background: white; border-radius: 8px; padding: 20px; width: 400px; max-width: 90%; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); } #dtqs-settings-dialog h2 { margin-top: 0; margin-bottom: 15px; font-size: 18px; border-bottom: 1px solid #eee; padding-bottom: 10px; } .dtqs-setting-item { display: flex; align-items: center; margin-bottom: 15px; } .dtqs-setting-item label { margin-right: 10px; flex-grow: 1; display: flex; align-items: center; } .dtqs-setting-item input[type="checkbox"] { margin-right: 5px; vertical-align: middle; } .dtqs-setting-item select { padding: 5px; border-radius: 4px; border: 1px solid #ddd; } .dtqs-buttons { display: flex; justify-content: flex-end; margin-top: 20px; gap: 10px; } .dtqs-buttons button { padding: 6px 12px; border-radius: 4px; border: 1px solid #ccc; background: #f5f5f5; cursor: pointer; } .dtqs-buttons button:hover { background: #e5e5e5; } .topic-nav-container { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; position: relative; width: 100%; } .hide-nav-buttons .prev-topic, .hide-nav-buttons .next-topic { display: none; } .topic-nav-button { display: flex; align-items: center; padding: 5px 8px; cursor: pointer; border-radius: 4px; transition: all 0.2s ease; } .topic-nav-button:hover { background-color: rgba(255,255,255,0.2); } .topic-nav-title { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; margin: 0 6px; font-weight: 500; } .center-button { grid-column: 2; z-index: 1; margin: 0 15px; } .prev-topic { grid-column: 1; justify-self: end; } .next-topic { grid-column: 3; justify-self: start; } #topic-list-viewer-button:hover { background-color: #0063b1; transform: translateX(-50%) scale(1.05); } #topic-list-viewer-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 9999; display: none; flex-direction: column; opacity: 0; transition: opacity 0.1s ease; } .topic-list-viewer-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 1; } .topic-list-viewer-wrapper { position: relative; z-index: 2; background-color: white; width: 100%; max-width: 1200px; height: 100%; overflow-y: auto; margin: 0 auto; display: flex; flex-direction: column; } #topic-list-viewer-container.visible { opacity: 1; } .topic-list-viewer-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; background-color: #f8f8f8; border-bottom: 1px solid #ddd; } .topic-list-viewer-header h3 { margin: 0; font-size: 18px; color: #333; } .topic-list-viewer-controls { display: flex; align-items: center; gap: 15px; } .source-link { color: #0078d7; text-decoration: none; font-size: 14px; } .source-link:hover { text-decoration: underline; } #topic-list-viewer-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #666; } .cache-status { padding: 8px 20px; background-color: #f0f7ff; color: #0063b1; font-size: 12px; border-bottom: 1px solid #ddd; } .cache-status.expired { background-color: #fff0f0; color: #d70000; } .topic-list-viewer-content { flex: 1; overflow-y: auto; padding: 20px; background-color: white; } .topic-list-viewer-content table { width: 100%; border-collapse: collapse; position: relative; } .topic-list-viewer-content th { text-align: left; padding: 10px; border-bottom: 2px solid #ddd; color: #555; font-weight: bold; } .topic-list-viewer-content td { padding: 10px; border-bottom: 1px solid #eee; } .topic-list-viewer-content tr:hover { background-color: #f5f5f5; } /* Current topic highlight style */ .topic-list-viewer-content tr.current-topic { background-color: #e6f7ff; border-left: 3px solid #1890ff; } .topic-list-viewer-content tr.current-topic:hover { background-color: #d4edff; } .topic-list-viewer-content tr.current-topic td:first-child { padding-left: 7px; /* 10px - 3px border */ } .topic-list-data { width: 60%; } .topic-list-replies, .topic-list-views, .topic-list-activity { width: 13%; text-align: center; } /* Dark mode styles */ .topic-list-viewer-dark-mode #topic-list-viewer-button { background-color: #2196f3; box-shadow: 0 2px 5px rgba(0,0,0,0.4); } .topic-list-viewer-dark-mode #topic-list-viewer-button:hover { background-color: #1976d2; } .topic-list-viewer-dark-mode .topic-list-viewer-overlay { background-color: rgba(0,0,0,0.85); } .topic-list-viewer-dark-mode .topic-list-viewer-wrapper { background-color: #222; color: #e0e0e0; } .topic-list-viewer-dark-mode .topic-list-viewer-header { background-color: #2c2c2c; border-bottom: 1px solid #444; } .topic-list-viewer-dark-mode .topic-list-viewer-header h3 { color: #e0e0e0; } .topic-list-viewer-dark-mode .source-link { color: #64b5f6; } .topic-list-viewer-dark-mode #topic-list-viewer-close { color: #aaa; } .topic-list-viewer-dark-mode .cache-status { background-color: #1a3a5a; color: #90caf9; border-bottom: 1px solid #444; } .topic-list-viewer-dark-mode .cache-status.expired { background-color: #5a1a1a; color: #ef9a9a; } .topic-list-viewer-dark-mode .topic-list-viewer-content { background-color: #333; color: #e0e0e0; } .topic-list-viewer-dark-mode .topic-list-viewer-content th { border-bottom: 2px solid #555; color: #bbb; } .topic-list-viewer-dark-mode .topic-list-viewer-content td { border-bottom: 1px solid #444; } .topic-list-viewer-dark-mode .topic-list-viewer-content tr:hover { background-color: #3a3a3a; } /* Current topic highlight style in dark mode */ .topic-list-viewer-dark-mode .topic-list-viewer-content tr.current-topic { background-color: #1a365d; border-left: 3px solid #1890ff; } .topic-list-viewer-dark-mode .topic-list-viewer-content tr.current-topic:hover { background-color: #234979; } .topic-list-viewer-dark-mode .topic-list-viewer-content a { color: #64b5f6; } .topic-list-viewer-dark-mode .topic-list-viewer-content a:visited { color: #b39ddb; } @media (max-width: 768px) { .topic-list-viewer-content { padding: 10px; } .topic-list-data { width: 70%; } .topic-list-replies, .topic-list-views, .topic-list-activity { width: 10%; } } `) // Initialize after the page has loaded if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init) } else { init() } })()