187 lines
6.4 KiB
TypeScript
187 lines
6.4 KiB
TypeScript
/**
|
|
* Theme Hook
|
|
* Handles dark mode detection and Dark Reader compatibility
|
|
* Uses MutationObserver for efficient Dark Reader detection
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react'
|
|
|
|
export function useTheme() {
|
|
const [isDark, setIsDark] = useState(false)
|
|
const [hasDarkReader, setHasDarkReader] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const html = document.documentElement
|
|
|
|
// Helper function to apply/remove dark mode
|
|
const applyDarkMode = (shouldBeDark: boolean) => {
|
|
setIsDark(shouldBeDark)
|
|
if (shouldBeDark) {
|
|
html.classList.add('dark')
|
|
html.setAttribute('data-theme', 'dark')
|
|
} else {
|
|
html.classList.remove('dark')
|
|
html.setAttribute('data-theme', 'light')
|
|
}
|
|
}
|
|
|
|
// Enhanced Dark Reader detection with multiple methods
|
|
const detectDarkReader = (): boolean => {
|
|
// Method 1: Check for Dark Reader data attributes on html element
|
|
const hasDarkReaderAttributes =
|
|
html.hasAttribute('data-darkreader-mode') ||
|
|
html.hasAttribute('data-darkreader-scheme') ||
|
|
html.hasAttribute('data-darkreader-policy')
|
|
|
|
// Method 2: Check for Dark Reader stylesheet or meta tags
|
|
const hasDarkReaderMeta =
|
|
document.querySelector('meta[name="darkreader"]') !== null ||
|
|
document.querySelector('style[data-darkreader]') !== null
|
|
|
|
// Method 3: Check computed styles for filter/invert (Dark Reader uses CSS filters)
|
|
const computedStyle = window.getComputedStyle(html)
|
|
const hasFilter = computedStyle.filter && computedStyle.filter !== 'none'
|
|
const hasInvert = computedStyle.filter?.includes('invert') ||
|
|
computedStyle.filter?.includes('brightness')
|
|
|
|
// Method 4: Check for Dark Reader's characteristic background color
|
|
// Dark Reader often sets a specific dark background
|
|
const bgColor = computedStyle.backgroundColor
|
|
const isDarkReaderBg = bgColor === 'rgb(24, 26, 27)' ||
|
|
bgColor === 'rgb(18, 18, 18)' ||
|
|
(window.matchMedia('(prefers-color-scheme: dark)').matches &&
|
|
bgColor !== 'rgba(0, 0, 0, 0)' &&
|
|
bgColor !== 'transparent' &&
|
|
!html.classList.contains('dark'))
|
|
|
|
// Method 5: Check for Dark Reader injected styles
|
|
const styleSheets = Array.from(document.styleSheets)
|
|
const hasDarkReaderStylesheet = styleSheets.some(sheet => {
|
|
try {
|
|
const href = sheet.href || ''
|
|
return href.includes('darkreader') ||
|
|
(sheet.ownerNode as Element)?.getAttribute('data-darkreader') !== null
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
return hasDarkReaderAttributes ||
|
|
hasDarkReaderMeta ||
|
|
(hasFilter && hasInvert) ||
|
|
isDarkReaderBg ||
|
|
hasDarkReaderStylesheet
|
|
}
|
|
|
|
// Check system preference
|
|
const checkSystemPreference = (): boolean => {
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
}
|
|
|
|
// Update theme based on current state
|
|
const updateTheme = () => {
|
|
const darkReaderDetected = detectDarkReader()
|
|
const systemPrefersDark = checkSystemPreference()
|
|
|
|
setHasDarkReader(darkReaderDetected)
|
|
|
|
// Only apply dark mode if system prefers it AND Dark Reader is not active
|
|
if (systemPrefersDark && !darkReaderDetected) {
|
|
applyDarkMode(true)
|
|
} else {
|
|
applyDarkMode(false)
|
|
}
|
|
}
|
|
|
|
// Initial check
|
|
updateTheme()
|
|
|
|
// Listen for system preference changes
|
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
const handleSystemPreferenceChange = (e: MediaQueryListEvent) => {
|
|
updateTheme()
|
|
}
|
|
|
|
// Modern browsers
|
|
if (mediaQuery.addEventListener) {
|
|
mediaQuery.addEventListener('change', handleSystemPreferenceChange)
|
|
} else {
|
|
// Fallback for older browsers
|
|
mediaQuery.addListener(handleSystemPreferenceChange)
|
|
}
|
|
|
|
// MutationObserver for Dark Reader attribute changes (more efficient than setInterval)
|
|
const observer = new MutationObserver((mutations) => {
|
|
let shouldUpdate = false
|
|
|
|
mutations.forEach((mutation) => {
|
|
// Check if Dark Reader attributes were added/removed
|
|
if (mutation.type === 'attributes') {
|
|
const attrName = mutation.attributeName
|
|
if (attrName?.startsWith('data-darkreader') ||
|
|
attrName === 'class' ||
|
|
attrName === 'data-theme') {
|
|
shouldUpdate = true
|
|
}
|
|
}
|
|
|
|
// Check if Dark Reader elements were added/removed
|
|
if (mutation.type === 'childList') {
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
const el = node as Element
|
|
if (el.hasAttribute?.('data-darkreader') ||
|
|
el.tagName === 'META' && el.getAttribute('name') === 'darkreader' ||
|
|
el.tagName === 'STYLE' && el.hasAttribute('data-darkreader')) {
|
|
shouldUpdate = true
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
if (shouldUpdate) {
|
|
updateTheme()
|
|
}
|
|
})
|
|
|
|
// Observe html element for Dark Reader attribute changes
|
|
observer.observe(html, {
|
|
attributes: true,
|
|
attributeFilter: ['data-darkreader-mode', 'data-darkreader-scheme', 'data-darkreader-policy', 'class', 'data-theme'],
|
|
childList: true,
|
|
subtree: false
|
|
})
|
|
|
|
// Also observe document head for Dark Reader meta/stylesheets
|
|
if (document.head) {
|
|
observer.observe(document.head, {
|
|
childList: true,
|
|
subtree: false
|
|
})
|
|
}
|
|
|
|
// Fallback: Periodic check (reduced frequency, only as safety net)
|
|
// This catches edge cases where MutationObserver might miss something
|
|
const fallbackInterval = setInterval(() => {
|
|
const currentDarkReader = detectDarkReader()
|
|
if (currentDarkReader !== hasDarkReader) {
|
|
updateTheme()
|
|
}
|
|
}, 5000) // Check every 5 seconds (reduced from 2 seconds)
|
|
|
|
return () => {
|
|
// Cleanup
|
|
if (mediaQuery.removeEventListener) {
|
|
mediaQuery.removeEventListener('change', handleSystemPreferenceChange)
|
|
} else {
|
|
mediaQuery.removeListener(handleSystemPreferenceChange)
|
|
}
|
|
observer.disconnect()
|
|
clearInterval(fallbackInterval)
|
|
}
|
|
}, [])
|
|
|
|
return { isDark, hasDarkReader }
|
|
}
|