// ==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.5.0
// @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
// ==/UserScript==
;(async function () {
'use strict'
// Configuration
const CONFIG = {
// Settings storage key
SETTINGS_KEY: 'discourse_topic_switcher_settings',
// 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,
// Default language (en or zh-CN)
DEFAULT_LANGUAGE: 'en',
}
// User settings with defaults
let userSettings = {
language: CONFIG.DEFAULT_LANGUAGE,
showNavigationButtons: true,
darkMode: 'auto', // auto, light, dark
// Custom hotkey settings
hotkeys: {
showTopicList: 'Alt+KeyQ',
nextTopic: 'Alt+KeyW',
prevTopic: 'Alt+KeyE',
},
}
// Pre-initialized site-specific keys (calculated once at script load)
const SITE_CACHE_KEY = `${CONFIG.CACHE_KEY_BASE}_${window.location.hostname}`
const SITE_SETTINGS_KEY = `${CONFIG.SETTINGS_KEY}_${window.location.hostname}`
// Internationalization support
const I18N = {
en: {
viewTopicList: 'View topic list (press Alt + Q)',
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',
darkMode: 'Dark Mode',
darkModeAuto: 'Auto',
darkModeLight: 'Light',
darkModeDark: 'Dark',
// Hotkey settings
hotkeys: 'Hotkeys',
hotkeyShowTopicList: 'Show topic list',
hotkeyNextTopic: 'Next topic',
hotkeyPrevTopic: 'Previous topic',
hotkeyInputPlaceholder: 'e.g., Alt+KeyQ, Ctrl+KeyK, KeyG',
hotkeyInvalidFormat: 'Invalid hotkey format',
},
'zh-CN': {
viewTopicList: '查看话题列表(按 Alt + Q 键)',
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: '显示导航按钮',
darkMode: '深色模式',
darkModeAuto: '自动',
darkModeLight: '浅色',
darkModeDark: '深色',
// Hotkey settings
hotkeys: '快捷键',
hotkeyShowTopicList: '显示话题列表',
hotkeyNextTopic: '下一个话题',
hotkeyPrevTopic: '上一个话题',
hotkeyInputPlaceholder: '例如:Alt+KeyQ, Ctrl+KeyK, KeyG',
hotkeyInvalidFormat: '快捷键格式无效',
},
}
/**
* Load user settings from storage
*/
async function loadUserSettings() {
const savedSettings = await GM.getValue(SITE_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
*/
async function saveUserSettings() {
await GM.setValue(SITE_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
*/
async 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')}
${t('hotkeys')}
`
// Add dialog to overlay
overlay.appendChild(dialog)
// Add overlay to page
document.body.appendChild(overlay)
// Add event listeners
const saveButton = document.getElementById('dtqs-settings-save')
const cancelButton = document.getElementById('dtqs-settings-cancel')
addTouchSupport(saveButton, async () => {
// Save language setting
const languageSelect = document.getElementById('dtqs-language-select')
userSettings.language = languageSelect.value
// Save dark mode setting
const darkModeSelect = document.getElementById('dtqs-dark-mode-select')
userSettings.darkMode = darkModeSelect.value
// Save navigation buttons setting
const showNavButtons = document.getElementById('dtqs-show-nav-buttons')
userSettings.showNavigationButtons = showNavButtons.checked
// Save hotkey settings with validation
const hotkeyShowList = document.getElementById('dtqs-hotkey-show-list')
const hotkeyNextTopic = document.getElementById('dtqs-hotkey-next-topic')
const hotkeyPrevTopic = document.getElementById('dtqs-hotkey-prev-topic')
// Validate hotkey format
const hotkeyPattern =
/^(Ctrl\+|Alt\+|Shift\+|Meta\+)*(Key[A-Z]|Digit[0-9]|Space|Enter|Escape|Backspace|Tab|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|F[1-9]|F1[0-2])$/
const hotkeys = {
showTopicList: hotkeyShowList.value.trim(),
nextTopic: hotkeyNextTopic.value.trim(),
prevTopic: hotkeyPrevTopic.value.trim(),
}
// Validate each hotkey
for (const [key, value] of Object.entries(hotkeys)) {
if (value && !hotkeyPattern.test(value)) {
alert(`${t('hotkeyInvalidFormat')}: ${value}`)
return
}
}
// Check for duplicate hotkeys
const hotkeyValues = Object.values(hotkeys).filter((v) => v)
const uniqueHotkeys = new Set(hotkeyValues)
if (hotkeyValues.length !== uniqueHotkeys.size) {
alert('Duplicate hotkeys are not allowed')
return
}
userSettings.hotkeys = hotkeys
// Save settings
await saveUserSettings()
// Update language
currentLanguage = userSettings.language
// Update dark mode
detectDarkMode()
// 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)
}
})
addTouchSupport(cancelButton, 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
let isMobileDevice = false // Mobile device detection
/**
* Detect if the current device is a mobile device
*/
function detectMobileDevice() {
// Check user agent for mobile devices
const userAgent = navigator.userAgent || navigator.vendor || window.opera
const mobileRegex =
/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i
// Check screen width
const isSmallScreen = window.innerWidth <= 768
// Check for touch support
const hasTouchSupport =
'ontouchstart' in window || navigator.maxTouchPoints > 0
// Combine all checks
isMobileDevice =
mobileRegex.test(userAgent) || (isSmallScreen && hasTouchSupport)
console.log(`[DTQS] Mobile device detection: ${isMobileDevice}`)
// Add mobile class to body for CSS targeting
if (isMobileDevice) {
document.body.classList.add('dtqs-mobile-device')
} else {
document.body.classList.remove('dtqs-mobile-device')
}
return isMobileDevice
}
/**
* Detect dark mode based on user settings
*/
function detectDarkMode() {
let shouldUseDarkMode = false
// Check user's dark mode preference
switch (userSettings.darkMode) {
case 'dark':
// Force dark mode
shouldUseDarkMode = true
console.log('[DTQS] Dark mode: Force enabled by user setting')
break
case 'light':
// Force light mode
shouldUseDarkMode = false
console.log('[DTQS] Dark mode: Force disabled by user setting')
break
case 'auto':
default:
// Auto mode - check system and site preferences
if (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
shouldUseDarkMode = systemDarkMode || discourseBodyClass
console.log(
`[DTQS] Dark mode (auto): System: ${systemDarkMode}, Site: ${discourseBodyClass}, Final: ${shouldUseDarkMode}`
)
}
break
}
// Update global dark mode state
isDarkMode = shouldUseDarkMode
// 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 (window.matchMedia) {
// Listen for system dark mode changes
const darkModeMediaQuery = window.matchMedia(
'(prefers-color-scheme: dark)'
)
// Add change listener (only trigger if user is in auto mode)
const handleSystemChange = (e) => {
if (userSettings.darkMode === 'auto') {
detectDarkMode()
}
}
if (darkModeMediaQuery.addEventListener) {
darkModeMediaQuery.addEventListener('change', handleSystemChange)
} else if (darkModeMediaQuery.addListener) {
// Fallback for older browsers
darkModeMediaQuery.addListener(handleSystemChange)
}
// Listen for Discourse theme changes (only trigger if user is in auto mode)
const observer = new MutationObserver((mutations) => {
if (userSettings.darkMode === 'auto') {
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
*/
async function init() {
// Load user settings
await loadUserSettings()
// Load cached topic list from storage
await loadCachedTopicList()
// Detect mobile device
detectMobileDevice()
// Detect dark mode
detectDarkMode()
// Set up dark mode listener
// setupDarkModeListener()
// Set up mobile device detection on window resize
window.addEventListener('resize', () => {
detectMobileDevice()
})
// 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) {
// Remove the temporary container from document.body
tempContainer.remove()
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) {
// Remove the temporary container from document.body
tempContainer.remove()
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
*/
async 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
await GM.setValue(SITE_CACHE_KEY, {
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
*/
async function loadCachedTopicList() {
const cache = await GM.getValue(SITE_CACHE_KEY)
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')
addTouchSupport(prevButton, navigateToPrevTopic)
// Create center button
const centerButton = document.createElement('div')
centerButton.className = 'topic-nav-button center-button'
centerButton.innerHTML = `
`
centerButton.title = t('viewTopicList')
addTouchSupport(centerButton, toggleTopicList)
// Create next topic button
const nextButton = document.createElement('div')
nextButton.className = 'topic-nav-button next-topic'
nextButton.innerHTML = `
`
nextButton.title = t('nextTopic')
addTouchSupport(nextButton, 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()
}
/**
* Check if any unwanted modifier keys are pressed
* @param {KeyboardEvent} event - The keyboard event
* @returns {boolean} True if any unwanted modifier key is pressed
*/
function hasUnwantedModifierKeys(event) {
return event.shiftKey || event.ctrlKey || event.metaKey
}
/**
* Check if the focus is on an input element
* @returns {boolean} True if focus is on an input element
*/
function isFocusOnInput() {
const activeElement = document.activeElement
if (!activeElement) return false
const tagName = activeElement.tagName.toLowerCase()
const inputTypes = ['input', 'textarea', 'select']
// Check if it's an input element
if (inputTypes.includes(tagName)) {
return true
}
// Check if it's a contenteditable element
if (activeElement.contentEditable === 'true') {
return true
}
// Check if it's inside a contenteditable element
let parent = activeElement.parentElement
while (parent) {
if (parent.contentEditable === 'true') {
return true
}
parent = parent.parentElement
}
return false
}
/**
* Parse hotkey string into components
* @param {string} hotkeyStr - Hotkey string like "Alt+KeyQ" or "Ctrl+Shift+KeyA"
* @returns {Object} - Object with modifier flags and key code
*/
function parseHotkey(hotkeyStr) {
if (!hotkeyStr || typeof hotkeyStr !== 'string') {
return null
}
const parts = hotkeyStr.split('+')
const result = {
ctrl: false,
alt: false,
shift: false,
meta: false,
code: null,
}
for (const part of parts) {
const trimmed = part.trim()
switch (trimmed) {
case 'Ctrl':
result.ctrl = true
break
case 'Alt':
result.alt = true
break
case 'Shift':
result.shift = true
break
case 'Meta':
result.meta = true
break
default:
result.code = trimmed
break
}
}
return result.code ? result : null
}
/**
* Check if event matches the parsed hotkey
* @param {KeyboardEvent} event - Keyboard event
* @param {Object} parsedHotkey - Parsed hotkey object
* @returns {boolean} - True if event matches hotkey
*/
function matchesHotkey(event, parsedHotkey) {
if (!parsedHotkey) {
return false
}
return (
event.ctrlKey === parsedHotkey.ctrl &&
event.altKey === parsedHotkey.alt &&
event.shiftKey === parsedHotkey.shift &&
event.metaKey === parsedHotkey.meta &&
event.code === parsedHotkey.code
)
}
/**
* Add a hotkey listener
*/
function addHotkeyListener() {
document.addEventListener(
'keydown',
function (event) {
// Skip if unwanted modifier keys are pressed (but allow Alt)
// if (hasUnwantedModifierKeys(event)) {
// return
// }
// Skip if focus is on an input element
if (isFocusOnInput()) {
return
}
// Check for hotkeys only on topic pages
if (!isTopicPage()) {
return
}
console.log(
`[DTQS] keydown event: key=${event.key}, code=${event.code}, modifiers: Ctrl=${event.ctrlKey}, Alt=${event.altKey}, Shift=${event.shiftKey}, Meta=${event.metaKey}`
)
// Parse configured hotkeys
const showListHotkey = parseHotkey(userSettings.hotkeys.showTopicList)
const nextTopicHotkey = parseHotkey(userSettings.hotkeys.nextTopic)
const prevTopicHotkey = parseHotkey(userSettings.hotkeys.prevTopic)
// Check for show topic list hotkey
if (showListHotkey && matchesHotkey(event, showListHotkey)) {
event.preventDefault()
event.stopPropagation()
toggleTopicList()
return
}
// Check for next topic hotkey
if (nextTopicHotkey && matchesHotkey(event, nextTopicHotkey)) {
event.preventDefault()
event.stopPropagation()
navigateToNextTopic()
return
}
// Check for previous topic hotkey
if (prevTopicHotkey && matchesHotkey(event, prevTopicHotkey)) {
event.preventDefault()
event.stopPropagation()
navigateToPrevTopic()
return
}
// ESC key to close topic list (hardcoded for usability)
if (
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
!event.metaKey &&
event.key === 'Escape' &&
isListVisible
) {
event.preventDefault()
event.stopPropagation()
hideTopicList()
return
}
},
true
)
}
/**
* 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 = `
`
}
// 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 = `