// ==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 = `
`
}
// 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 = `