mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 02:16:48 +00:00
292 lines
9.1 KiB
TypeScript
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,
|
|
}
|
|
})
|