From 476c85975fd500f1c0344ed110607a714c7a9389 Mon Sep 17 00:00:00 2001 From: ambar Date: Thu, 28 Mar 2024 20:46:06 +0800 Subject: [PATCH] feat: respond to the change of system theme appearance --- .changeset/perfect-islands-shave.md | 6 + packages/core/src/runtime/clientEntry.tsx | 14 ++- .../src/components/SwitchAppearance/index.tsx | 27 +--- packages/theme-default/src/logic/index.ts | 1 + .../theme-default/src/logic/useAppearance.ts | 117 +++++++++++------- .../theme-default/src/logic/useHandler.ts | 10 ++ .../theme-default/src/logic/useMediaQuery.ts | 21 ++++ .../src/logic/useStorageValue.ts | 42 +++++++ 8 files changed, 165 insertions(+), 73 deletions(-) create mode 100644 .changeset/perfect-islands-shave.md create mode 100644 packages/theme-default/src/logic/useHandler.ts create mode 100644 packages/theme-default/src/logic/useMediaQuery.ts create mode 100644 packages/theme-default/src/logic/useStorageValue.ts diff --git a/.changeset/perfect-islands-shave.md b/.changeset/perfect-islands-shave.md new file mode 100644 index 000000000..05f9108c5 --- /dev/null +++ b/.changeset/perfect-islands-shave.md @@ -0,0 +1,6 @@ +--- +'@rspress/theme-default': minor +'@rspress/core': minor +--- + +feat: respond to the change of system theme appearance diff --git a/packages/core/src/runtime/clientEntry.tsx b/packages/core/src/runtime/clientEntry.tsx index e8235fbd0..cf38a81ce 100644 --- a/packages/core/src/runtime/clientEntry.tsx +++ b/packages/core/src/runtime/clientEntry.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { isProduction } from '@rspress/shared'; import siteData from 'virtual-site-data'; import { @@ -12,7 +12,7 @@ import { App, initPageData } from './App'; const enableSSG = siteData.ssg; // eslint-disable-next-line import/no-commonjs -const { default: Theme } = require('@theme'); +const { default: Theme, useThemeState } = require('@theme'); export async function renderInBrowser() { const container = document.getElementById('root')!; @@ -23,10 +23,14 @@ export async function renderInBrowser() { ); return function RootApp() { const [data, setData] = useState(initialPageData); - const [theme, setTheme] = useState<'light' | 'dark'>('light'); + const [theme, setTheme] = useThemeState(); return ( - - + ({ theme, setTheme }), [theme, setTheme])} + > + ({ data, setData }), [data, setData])} + > diff --git a/packages/theme-default/src/components/SwitchAppearance/index.tsx b/packages/theme-default/src/components/SwitchAppearance/index.tsx index b3386873c..453834dbd 100644 --- a/packages/theme-default/src/components/SwitchAppearance/index.tsx +++ b/packages/theme-default/src/components/SwitchAppearance/index.tsx @@ -1,41 +1,16 @@ -import { useContext, useEffect, useState } from 'react'; +import { useContext } from 'react'; import { ThemeContext } from '@rspress/runtime'; import SunSvg from '@theme-assets/sun'; import MoonSvg from '@theme-assets/moon'; -import { - getToggle, - isDarkMode, - updateUserPreferenceFromStorage, -} from '../../logic/useAppearance'; import { SvgWrapper } from '../SvgWrapper'; export function SwitchAppearance({ onClick }: { onClick?: () => void }) { const { theme, setTheme } = useContext(ThemeContext); - const toggleAppearance = getToggle(); - const updateAppearanceAndTheme = () => { - const isDark = updateUserPreferenceFromStorage(); - setTheme(isDark ? 'dark' : 'light'); - }; - - useEffect(() => { - if (isDarkMode()) { - setTheme('dark'); - } - if (typeof window !== 'undefined') { - window.addEventListener('storage', updateAppearanceAndTheme); - } - return () => { - if (typeof window !== 'undefined') { - window.removeEventListener('storage', updateAppearanceAndTheme); - } - }; - }, []); return (
{ setTheme(theme === 'dark' ? 'light' : 'dark'); - toggleAppearance(); onClick?.(); }} className="md:mr-2 rspress-nav-appearance" diff --git a/packages/theme-default/src/logic/index.ts b/packages/theme-default/src/logic/index.ts index 5320a9f1f..f5712fb90 100644 --- a/packages/theme-default/src/logic/index.ts +++ b/packages/theme-default/src/logic/index.ts @@ -7,4 +7,5 @@ export { setup, bindingAsideScroll, scrollToTarget } from './sideEffects'; export { usePathUtils } from './usePathUtils'; export { useFullTextSearch } from './useFullTextSearch'; export { useRedirect4FirstVisit } from './useRedirect4FirstVisit'; +export { useThemeState } from './useAppearance'; export * from './utils'; diff --git a/packages/theme-default/src/logic/useAppearance.ts b/packages/theme-default/src/logic/useAppearance.ts index dc4e2260d..cef9047a8 100644 --- a/packages/theme-default/src/logic/useAppearance.ts +++ b/packages/theme-default/src/logic/useAppearance.ts @@ -1,5 +1,9 @@ import siteData from 'virtual-site-data'; import { APPEARANCE_KEY } from '@rspress/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { useHandler } from './useHandler'; +import { useMediaQuery } from './useMediaQuery'; +import { useStorageValue } from './useStorageValue'; declare global { interface Window { @@ -8,54 +12,83 @@ declare global { } } -let classList: DOMTokenList | undefined; -// Determine if the theme mode of the user's operating system is dark -let userPreference: string; -let query: MediaQueryList; +// Value to be used in the app +type ThemeValue = 'light' | 'dark'; +// Value to be stored +type ThemeConfigValue = ThemeValue | 'auto'; -const setClass = (dark: boolean): void => { - classList?.[dark ? 'add' : 'remove']('dark'); +const sanitize = (value: string): ThemeConfigValue => { + return ['light', 'dark', 'auto'].includes(value) + ? (value as ThemeConfigValue) + : 'auto'; }; -const updateAppearance = (): void => { - const disableDarkMode = siteData.themeConfig.darkMode === false; - // We set the RSPRESS_THEME as a global variable to determine whether the theme is dark or light. - const defaultTheme = window.RSPRESS_THEME ?? window.MODERN_THEME; - if (defaultTheme) { - setClass(defaultTheme === 'dark'); - return; - } - if (disableDarkMode) { - return; - } - updateUserPreferenceFromStorage(); -}; +const disableDarkMode = siteData.themeConfig.darkMode === false; -export const updateUserPreferenceFromStorage = () => { - const userPreference = localStorage.getItem(APPEARANCE_KEY) || 'auto'; - query = window.matchMedia('(prefers-color-scheme: dark)'); - const isDark = - userPreference === 'auto' ? query.matches : userPreference === 'dark'; - setClass(isDark); - return isDark; -}; +/** + * State provider for theme context. + */ +export const useThemeState = () => { + const matchesDark = useMediaQuery('(prefers-color-scheme: dark)'); + const [storedTheme, setStoredTheme] = useStorageValue(APPEARANCE_KEY); -if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { - // When user preference is auto,the modern theme will change with the system user's operating system theme. - // eslint-disable-next-line prefer-destructuring - classList = document.documentElement.classList; - updateAppearance(); -} + const getPreferredTheme = useHandler(() => { + if (disableDarkMode) { + return 'light'; + } + const sanitized = sanitize(storedTheme); + return sanitized === 'auto' ? (matchesDark ? 'dark' : 'light') : sanitized; + }); -export const isDarkMode = () => classList?.contains('dark'); + const [theme, setThemeInternal] = useState(() => { + if (typeof window === 'undefined') { + return 'light'; + } + // We set the RSPRESS_THEME as a global variable to determine whether the theme is dark or light. + const defaultTheme = window.RSPRESS_THEME ?? window.MODERN_THEME; + if (defaultTheme) { + return defaultTheme === 'dark' ? 'dark' : 'light'; + } + return getPreferredTheme(); + }); + const setTheme = useCallback( + (value: ThemeValue, storeValue: ThemeConfigValue = value) => { + if (disableDarkMode) { + return; + } + setThemeInternal(value); + setStoredTheme(storeValue); + setSkipEffect(true); + }, + [], + ); + + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + document.documentElement.style.colorScheme = theme; + }, [theme]); + + // Skip the first effect on mount, only run on updates + const [skipEffect, setSkipEffect] = useState(true); + useEffect(() => { + setSkipEffect(false); + }, [skipEffect]); -export const getToggle = () => { - return () => { - const isDark = isDarkMode(); - if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { - setClass(!isDark); - userPreference = isDark ? 'light' : 'dark'; - localStorage.setItem(APPEARANCE_KEY, userPreference); + // Update the theme when the localStorage changes + useEffect(() => { + if (skipEffect) { + return; } - }; + setTheme(getPreferredTheme(), sanitize(storedTheme)); + }, [storedTheme]); + + // Update the theme when the OS theme changes + useEffect(() => { + if (skipEffect) { + return; + } + setTheme(matchesDark ? 'dark' : 'light', 'auto'); + }, [matchesDark]); + + return [theme, setTheme] as const; }; diff --git a/packages/theme-default/src/logic/useHandler.ts b/packages/theme-default/src/logic/useHandler.ts new file mode 100644 index 000000000..ecb5ecdde --- /dev/null +++ b/packages/theme-default/src/logic/useHandler.ts @@ -0,0 +1,10 @@ +import { useRef } from 'react'; + +/** + * Create a memoized handler function with stable reference + */ +export const useHandler = any>(handler: T) => { + const handlerRef = useRef(handler); + handlerRef.current = handler; + return useRef(((...args: any[]) => handlerRef.current(...args)) as T).current; +}; diff --git a/packages/theme-default/src/logic/useMediaQuery.ts b/packages/theme-default/src/logic/useMediaQuery.ts new file mode 100644 index 000000000..150afd1c5 --- /dev/null +++ b/packages/theme-default/src/logic/useMediaQuery.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +/** + * Wrapper of CSS Media Query API + */ +export const useMediaQuery = (query: string) => { + const [matches, setMatches] = useState(() => { + return typeof window !== 'undefined' + ? window.matchMedia(query).matches + : false; + }); + + useEffect(() => { + const mediaQueryList = window.matchMedia(query); + const listener = (e: MediaQueryListEvent) => setMatches(e.matches); + mediaQueryList.addEventListener('change', listener); + return () => mediaQueryList.removeEventListener('change', listener); + }, [query]); + + return matches; +}; diff --git a/packages/theme-default/src/logic/useStorageValue.ts b/packages/theme-default/src/logic/useStorageValue.ts new file mode 100644 index 000000000..c8f969026 --- /dev/null +++ b/packages/theme-default/src/logic/useStorageValue.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * Read/update the value in localStorage, and keeping it in sync with other tabs. + */ +export const useStorageValue = (key: string, defaultValue = null) => { + const [value, setValueInternal] = useState(() => { + if (typeof window === 'undefined') { + return defaultValue; + } + return localStorage.getItem(key) ?? defaultValue; + }); + + const setValue = useCallback( + value => { + setValueInternal(prev => { + const next = typeof value === 'function' ? value(prev) : value; + if (next == null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, next); + } + return next; + }); + }, + [key], + ); + + useEffect(() => { + const listener = (e: StorageEvent) => { + if (e.key === key) { + setValueInternal(localStorage.getItem(key) ?? defaultValue); + } + }; + window.addEventListener('storage', listener); + return () => { + window.removeEventListener('storage', listener); + }; + }, [key, defaultValue]); + + return [value, setValue] as const; +};