Files
nocodb/packages/nc-gui/composables/useTheme.ts
2026-04-04 13:26:25 +00:00

292 lines
9.1 KiB
TypeScript

import { barcodeCache } from '../components/smartsheet/grid/canvas/utils/canvas'
import { isSharedViewRoute } from '~/utils/routeUtils'
export type ThemeMode = 'system' | 'light' | 'dark'
export const useTheme = createSharedComposable(() => {
const selectedTheme = ref<ThemeMode>('system')
const systemPreference = ref<'light' | 'dark'>('light')
const router = useRouter()
const route = router.currentRoute
/**
* Some pages are used in iframe which don't support dark theme yet, so disable dark theme for them.
*/
const disabledDarkThemeRouteNames = ['index-typeOrId-pricing', 'index-typeOrId-checkout-planId']
const isThemeEnabled = computed(() => {
return !disabledDarkThemeRouteNames.includes(route.value.name as string)
})
const isDark = computed(() => {
if (!isThemeEnabled.value) {
return false
}
if (selectedTheme.value === 'system') {
return systemPreference.value === 'dark'
}
return selectedTheme.value === 'dark'
})
const applyTheme = (dark: boolean) => {
if (typeof document !== 'undefined') {
document.documentElement.classList.add('theme-transition-off')
if (dark) {
document.documentElement.setAttribute('theme', 'dark')
document.documentElement.style.colorScheme = 'dark'
/**
* WindiCSS config uses `darkMode: 'class'`, so we add the `dark` class
* to `<html>` to enable `dark:` prefix-based utilities.
*
* We support dark mode globally using CSS variables defined for both
* `:root` (light theme) and `[theme='dark']` (dark theme).
*
* The `dark:` prefixed classes are used to selectively override the
* globally applied styles in dark mode, making it possible to combine
* global theme variables (`var(...)`) with per-component dark overrides.
*/
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark')
}
} else {
document.documentElement.removeAttribute('theme')
document.documentElement.classList.remove('dark')
document.documentElement.style.colorScheme = 'light'
}
forcedNextTick(() => {
document.documentElement.classList.remove('theme-transition-off')
})
}
}
const setTheme = (theme: ThemeMode) => {
selectedTheme.value = theme
if (typeof localStorage !== 'undefined') {
localStorage.setItem('nc-theme', theme)
}
}
const toggleTheme = () => {
const nextTheme = {
system: 'light',
light: 'dark',
dark: 'system',
}
setTheme((nextTheme[selectedTheme.value] as ThemeMode) ?? 'light')
}
/**
* Cached version for better performance in canvas rendering
*/
const colorCache = new Map<string, string>()
/**
* Converts any color format to rgba with specified opacity
* @param color - Color in hex, rgb, or rgba format
* @param opacity - Opacity value between 0 and 1
* @returns Color in rgba format
*/
const convertToRgba = (color: string, opacity: number): string => {
// If already rgba, extract rgb values and apply new opacity
if (color.startsWith('rgba')) {
const rgbaMatch = color.match(/rgba?\(([^)]+)\)/)
if (rgbaMatch) {
const values = rgbaMatch[1].split(',').map((v) => v.trim())
const r = values[0]
const g = values[1]
const b = values[2]
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
}
// If rgb, extract values and add opacity
if (color.startsWith('rgb')) {
const rgbMatch = color.match(/rgb\(([^)]+)\)/)
if (rgbMatch) {
const values = rgbMatch[1].split(',').map((v) => v.trim())
const r = values[0]
const g = values[1]
const b = values[2]
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
}
// If hex, convert to rgba
if (color.startsWith('#')) {
const hex = color.replace('#', '')
let r: number, g: number, b: number
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16)
g = parseInt(hex[1] + hex[1], 16)
b = parseInt(hex[2] + hex[2], 16)
} else if (hex.length === 6) {
r = parseInt(hex.slice(0, 2), 16)
g = parseInt(hex.slice(2, 4), 16)
b = parseInt(hex.slice(4, 6), 16)
} else {
console.warn(`Invalid hex color format: ${color}`)
return `rgba(0, 0, 0, ${opacity})`
}
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
// Fallback for unrecognized formats
console.warn(`Unrecognized color format: ${color}`)
return `rgba(0, 0, 0, ${opacity})`
}
/**
* Gets the computed color value from a CSS variable string with optional opacity
* @param cssVariableValue - The CSS variable string like 'var(--color-brand-50)'
* @param opacity - Optional opacity value between 0 and 1
* @returns The actual color value (hex, rgb, or rgba)
*/
const getColor: GetColorType = (cssVariableValue, darkCssVariableValue, opacity, options = {}) => {
// In some case we want different dark mode color which does not have mapping in css variable.
if (isDark.value) {
cssVariableValue = darkCssVariableValue ?? cssVariableValue
}
// bypass option is used only to toggle color based on isDark value
if (options.bypass) {
return cssVariableValue
}
const cacheKey = opacity !== undefined ? `${cssVariableValue}:${opacity}` : cssVariableValue
if (colorCache.has(cacheKey)) {
return colorCache.get(cacheKey)!
}
let baseColor = cssVariableValue
// Handle CSS variables
if (cssVariableValue.startsWith('var(')) {
const variableName = cssVariableValue.match(/var\((--[^)]+)\)/)?.[1]
if (!variableName) {
console.warn(`Invalid CSS variable format: ${cssVariableValue}`)
baseColor = cssVariableValue
} else {
// Get the computed value from the document root
const computedValue = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim()
if (!computedValue) {
console.warn(`CSS variable ${variableName} not found or has no value`)
baseColor = '#000000' // Fallback color
} else {
baseColor = computedValue
}
}
} else if (cssVariableValue.startsWith('--rgb-')) {
// Get the computed value from the document root
const computedValue = getComputedStyle(document.documentElement).getPropertyValue(cssVariableValue).trim()
if (!computedValue) {
console.warn(`CSS variable ${cssVariableValue} not found or has no value`)
baseColor = '#000000' // Fallback color
} else {
// Clamp opacity between 0 and 1
const clampedOpacity = Math.max(0, Math.min(1, opacity ?? 1))
baseColor = `rgba(${computedValue}, ${clampedOpacity})`
colorCache.set(cacheKey, baseColor)
return baseColor
}
}
// If no opacity specified, return the base color
if (opacity === undefined) {
colorCache.set(cacheKey, baseColor)
return baseColor
}
// Clamp opacity between 0 and 1
const clampedOpacity = Math.max(0, Math.min(1, opacity))
// Convert color to rgba format with opacity
const colorWithOpacity = convertToRgba(baseColor, clampedOpacity)
colorCache.set(cacheKey, colorWithOpacity)
return colorWithOpacity
}
const clearColorCache = () => {
colorCache.clear()
}
let initialized = false
const init = () => {
if (initialized || typeof window === 'undefined') return
initialized = true
const saved = localStorage.getItem('nc-theme') as ThemeMode
if (saved && ['system', 'light', 'dark'].includes(saved)) {
selectedTheme.value = saved
}
// Check for theme query parameter on shared views (without persisting to localStorage)
// Use 'nc-theme' to avoid conflicts with user form fields named 'theme'
const themeParam = route.value.query?.['nc-theme'] || route.value.query?.theme
if (isSharedViewRoute(route.value) && themeParam) {
const queryTheme = themeParam as string
if (['light', 'dark', 'system'].includes(queryTheme)) {
selectedTheme.value = queryTheme as ThemeMode
}
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
systemPreference.value = mediaQuery.matches ? 'dark' : 'light'
mediaQuery.addEventListener('change', (e) => {
systemPreference.value = e.matches ? 'dark' : 'light'
})
}
// Update selectedTheme when nc-theme is changed in another tab
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'nc-theme' && event.newValue) {
const newTheme = event.newValue as ThemeMode
if (['system', 'light', 'dark'].includes(newTheme) && newTheme !== selectedTheme.value) {
selectedTheme.value = newTheme
}
}
}
useEventListener(window, 'storage', handleStorageChange)
watch(isDark, applyTheme, { immediate: true })
watch(isDark, () => {
barcodeCache.clear()
clearColorCache()
})
init()
onMounted(init)
return {
selectedTheme,
isDark,
setTheme,
toggleTheme,
init,
isThemeEnabled,
getColor,
clearColorCache,
}
})