jan/web-app/src/hooks/useAppearance.ts

745 lines
25 KiB
TypeScript

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { localStorageKey } from '@/constants/localStorage'
import { RgbaColor } from 'react-colorful'
import { rgb, oklch, formatCss } from 'culori'
import { useTheme } from './useTheme'
export type FontSize = '14px' | '15px' | '16px' | '18px'
interface AppearanceState {
fontSize: FontSize
appBgColor: RgbaColor
appMainViewBgColor: RgbaColor
appPrimaryBgColor: RgbaColor
appAccentBgColor: RgbaColor
appDestructiveBgColor: RgbaColor
appMainViewTextColor: string
appPrimaryTextColor: string
appAccentTextColor: string
appDestructiveTextColor: string
appLeftPanelTextColor: string
setFontSize: (size: FontSize) => void
setAppBgColor: (color: RgbaColor) => void
setAppMainViewBgColor: (color: RgbaColor) => void
setAppPrimaryBgColor: (color: RgbaColor) => void
setAppAccentBgColor: (color: RgbaColor) => void
setAppDestructiveBgColor: (color: RgbaColor) => void
resetAppearance: () => void
}
const getBrightness = ({ r, g, b }: RgbaColor) =>
(r * 299 + g * 587 + b * 114) / 1000
export const fontSizeOptions = [
{ label: 'Small', value: '14px' as FontSize },
{ label: 'Medium', value: '15px' as FontSize },
{ label: 'Large', value: '16px' as FontSize },
{ label: 'Extra Large', value: '18px' as FontSize },
]
// Default appearance settings
const defaultFontSize: FontSize = '15px'
const defaultAppBgColor: RgbaColor = {
r: 25,
g: 25,
b: 25,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
}
const defaultLightAppBgColor: RgbaColor = {
r: 255,
g: 255,
b: 255,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
}
const defaultAppMainViewBgColor: RgbaColor = { r: 25, g: 25, b: 25, a: 1 }
const defaultLightAppMainViewBgColor: RgbaColor = {
r: 255,
g: 255,
b: 255,
a: 1,
}
const defaultAppPrimaryBgColor: RgbaColor = { r: 219, g: 88, b: 44, a: 1 }
const defaultLightAppPrimaryBgColor: RgbaColor = { r: 219, g: 88, b: 44, a: 1 }
const defaultAppAccentBgColor: RgbaColor = { r: 45, g: 120, b: 220, a: 1 }
const defaultLightAppAccentBgColor: RgbaColor = { r: 45, g: 120, b: 220, a: 1 }
const defaultAppDestructiveBgColor: RgbaColor = { r: 144, g: 60, b: 60, a: 1 }
const defaultLightAppDestructiveBgColor: RgbaColor = {
r: 217,
g: 95,
b: 95,
a: 1,
}
const defaultDarkLeftPanelTextColor: string = '#FFF'
const defaultLightLeftPanelTextColor: string = '#000'
// Helper function to check if two RGBA colors are equal
const isColorEqual = (color1: RgbaColor, color2: RgbaColor): boolean => {
return (
color1.r === color2.r &&
color1.g === color2.g &&
color1.b === color2.b &&
color1.a === color2.a
)
}
// Helper function to check if color is default (not customized)
export const isDefaultColor = (color: RgbaColor): boolean => {
return (
isColorEqual(color, defaultAppBgColor) ||
isColorEqual(color, defaultLightAppBgColor)
)
}
export const isDefaultColorMainView = (color: RgbaColor): boolean => {
return (
isColorEqual(color, defaultAppMainViewBgColor) ||
isColorEqual(color, defaultLightAppMainViewBgColor)
)
}
export const isDefaultColorPrimary = (color: RgbaColor): boolean => {
return (
isColorEqual(color, defaultAppPrimaryBgColor) ||
isColorEqual(color, defaultLightAppPrimaryBgColor)
)
}
export const isDefaultColorAccent = (color: RgbaColor): boolean => {
return (
isColorEqual(color, defaultAppAccentBgColor) ||
isColorEqual(color, defaultLightAppAccentBgColor)
)
}
export const isDefaultColorDestructive = (color: RgbaColor): boolean => {
return (
isColorEqual(color, defaultAppDestructiveBgColor) ||
isColorEqual(color, defaultLightAppDestructiveBgColor)
)
}
// Helper function to get default text color based on theme
export const getDefaultTextColor = (isDark: boolean): string => {
return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor
}
export const useAppearance = create<AppearanceState>()(
persist(
(set) => {
return {
fontSize: defaultFontSize,
appBgColor: defaultAppBgColor,
appMainViewBgColor: defaultAppMainViewBgColor,
appPrimaryBgColor: defaultAppPrimaryBgColor,
appAccentBgColor: defaultAppAccentBgColor,
appDestructiveBgColor: defaultAppDestructiveBgColor,
appLeftPanelTextColor: getDefaultTextColor(useTheme.getState().isDark),
appMainViewTextColor: getDefaultTextColor(useTheme.getState().isDark),
appPrimaryTextColor: getDefaultTextColor(useTheme.getState().isDark),
appAccentTextColor: getDefaultTextColor(useTheme.getState().isDark),
appDestructiveTextColor: '#FFF',
resetAppearance: () => {
const { isDark } = useTheme.getState()
// Reset font size
document.documentElement.style.setProperty(
'--font-size-base',
defaultFontSize
)
// Reset app background color
const defaultBg = isDark ? defaultAppBgColor : defaultLightAppBgColor
const culoriRgbBg = rgb({
mode: 'rgb',
r: defaultBg.r / 255,
g: defaultBg.g / 255,
b: defaultBg.b / 255,
alpha: defaultBg.a,
})
const oklchBgColor = oklch(culoriRgbBg)
document.documentElement.style.setProperty(
'--app-bg',
formatCss(oklchBgColor)
)
// Reset main view background color
const defaultMainView = isDark
? defaultAppMainViewBgColor
: defaultLightAppMainViewBgColor
const culoriRgbMainView = rgb({
mode: 'rgb',
r: defaultMainView.r / 255,
g: defaultMainView.g / 255,
b: defaultMainView.b / 255,
alpha: defaultMainView.a,
})
const oklchMainViewColor = oklch(culoriRgbMainView)
document.documentElement.style.setProperty(
'--app-main-view',
formatCss(oklchMainViewColor)
)
// Reset primary color
const defaultPrimary = isDark
? defaultAppPrimaryBgColor
: defaultLightAppPrimaryBgColor
const culoriRgbPrimary = rgb({
mode: 'rgb',
r: defaultPrimary.r / 255,
g: defaultPrimary.g / 255,
b: defaultPrimary.b / 255,
alpha: defaultPrimary.a,
})
const oklchPrimaryColor = oklch(culoriRgbPrimary)
document.documentElement.style.setProperty(
'--app-primary',
formatCss(oklchPrimaryColor)
)
// Reset accent color
const defaultAccent = isDark
? defaultAppAccentBgColor
: defaultLightAppAccentBgColor
const culoriRgbAccent = rgb({
mode: 'rgb',
r: defaultAccent.r / 255,
g: defaultAccent.g / 255,
b: defaultAccent.b / 255,
alpha: defaultAccent.a,
})
const oklchAccentColor = oklch(culoriRgbAccent)
document.documentElement.style.setProperty(
'--app-accent',
formatCss(oklchAccentColor)
)
// Reset destructive color
const defaultDestructive = isDark
? defaultAppDestructiveBgColor
: defaultLightAppDestructiveBgColor
const culoriRgbDestructive = rgb({
mode: 'rgb',
r: defaultDestructive.r / 255,
g: defaultDestructive.g / 255,
b: defaultDestructive.b / 255,
alpha: defaultDestructive.a,
})
const oklchDestructiveColor = oklch(culoriRgbDestructive)
document.documentElement.style.setProperty(
'--app-destructive',
formatCss(oklchDestructiveColor)
)
// Reset text colors
const defaultTextColor = getDefaultTextColor(isDark)
document.documentElement.style.setProperty(
'--text-color',
defaultTextColor
)
document.documentElement.style.setProperty(
'--app-left-panel-fg',
defaultTextColor
)
document.documentElement.style.setProperty(
'--app-main-view-fg',
defaultTextColor
)
document.documentElement.style.setProperty('--app-primary-fg', '#FFF')
document.documentElement.style.setProperty('--app-accent-fg', '#FFF')
document.documentElement.style.setProperty(
'--app-destructive-fg',
'#FFF'
)
// Update state
set({
fontSize: defaultFontSize,
appBgColor: defaultBg,
appMainViewBgColor: defaultMainView,
appPrimaryBgColor: defaultPrimary,
appAccentBgColor: defaultAccent,
appLeftPanelTextColor: defaultTextColor,
appMainViewTextColor: defaultTextColor,
appPrimaryTextColor: '#FFF',
appAccentTextColor: '#FFF',
appDestructiveBgColor: defaultDestructive,
appDestructiveTextColor: '#FFF',
})
},
setFontSize: (size: FontSize) => {
// Update CSS variable
document.documentElement.style.setProperty('--font-size-base', size)
// Update state
set({ fontSize: size })
},
setAppBgColor: (color: RgbaColor) => {
// Get the current theme state
const { isDark } = useTheme.getState()
// If color is being set to default, use theme-appropriate default
let finalColor = color
if (isDefaultColor(color)) {
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
}
// Convert RGBA to a format culori can work with
const culoriRgb = rgb({
mode: 'rgb',
r: finalColor.r / 255,
g: finalColor.g / 255,
b: finalColor.b / 255,
alpha: finalColor.a,
})
// Convert to OKLCH for CSS variable
const oklchColor = oklch(culoriRgb)
// Update CSS variable with OKLCH
document.documentElement.style.setProperty(
'--app-bg',
formatCss(oklchColor)
)
// Calculate text color based on background brightness or use default based on theme
let textColor: string
if (isDefaultColor(color)) {
// If using default background, use default text color based on theme
textColor = getDefaultTextColor(isDark)
} else {
// Otherwise calculate based on brightness
textColor = getBrightness(finalColor) > 128 ? '#000' : '#FFF'
}
// Update CSS variable for text color
document.documentElement.style.setProperty('--text-color', textColor)
// Store the original RGBA and calculated text color in state
set({ appBgColor: finalColor, appLeftPanelTextColor: textColor })
},
setAppMainViewBgColor: (color: RgbaColor) => {
// Get the current theme state
const { isDark } = useTheme.getState()
// If color is being set to default, use theme-appropriate default
let finalColorMainView = color
if (isDefaultColorMainView(color)) {
finalColorMainView = isDark
? defaultAppMainViewBgColor
: defaultLightAppMainViewBgColor
}
// Convert RGBA to a format culori can work with
const culoriRgb = rgb({
mode: 'rgb',
r: finalColorMainView.r / 255,
g: finalColorMainView.g / 255,
b: finalColorMainView.b / 255,
alpha: finalColorMainView.a,
})
// Convert to OKLCH for CSS variable
const oklchColor = oklch(culoriRgb)
// Update CSS variable with OKLCH
document.documentElement.style.setProperty(
'--app-main-view',
formatCss(oklchColor)
)
// Calculate text color based on background brightness or use default based on theme
let textColor: string
if (isDefaultColor(color)) {
// If using default background, use default text color based on theme
textColor = getDefaultTextColor(isDark)
} else {
// Otherwise calculate based on brightness
textColor =
getBrightness(finalColorMainView) > 128 ? '#000' : '#FFF'
}
// Update CSS variable for text color
document.documentElement.style.setProperty(
'--app-main-view-fg',
textColor
)
// Store the original RGBA and calculated text color in state
set({
appMainViewBgColor: finalColorMainView,
appMainViewTextColor: textColor,
})
},
setAppPrimaryBgColor: (color: RgbaColor) => {
// Get the current theme state
const { isDark } = useTheme.getState()
// If color is being set to default, use theme-appropriate default
let finalColorPrimary = color
if (isDefaultColorPrimary(color)) {
finalColorPrimary = isDark
? defaultAppPrimaryBgColor
: defaultLightAppPrimaryBgColor
}
// Convert RGBA to a format culori can work with
const culoriRgb = rgb({
mode: 'rgb',
r: finalColorPrimary.r / 255,
g: finalColorPrimary.g / 255,
b: finalColorPrimary.b / 255,
alpha: finalColorPrimary.a,
})
// Convert to OKLCH for CSS variable
const oklchColor = oklch(culoriRgb)
// Update CSS variable with OKLCH
document.documentElement.style.setProperty(
'--app-primary',
formatCss(oklchColor)
)
// Calculate text color based on background brightness or use default based on theme
let textColor: string
if (isDefaultColorPrimary(color)) {
// If using default background, use default text color based on theme
textColor = '#FFF'
} else {
// Otherwise calculate based on brightness
textColor = getBrightness(finalColorPrimary) > 128 ? '#000' : '#FFF'
}
// Update CSS variable for text color
document.documentElement.style.setProperty(
'--app-primary-fg',
textColor
)
// Store the original RGBA and calculated text color in state
set({
appPrimaryBgColor: finalColorPrimary,
appPrimaryTextColor: textColor,
})
},
setAppAccentBgColor: (color: RgbaColor) => {
// Get the current theme state
const { isDark } = useTheme.getState()
// If color is being set to default, use theme-appropriate default
let finalColorAccent = color
if (isDefaultColorAccent(color)) {
finalColorAccent = isDark
? defaultAppAccentBgColor
: defaultLightAppAccentBgColor
}
// Convert RGBA to a format culori can work with
const culoriRgb = rgb({
mode: 'rgb',
r: finalColorAccent.r / 255,
g: finalColorAccent.g / 255,
b: finalColorAccent.b / 255,
alpha: finalColorAccent.a,
})
// Convert to OKLCH for CSS variable
const oklchColor = oklch(culoriRgb)
// Update CSS variable with OKLCH
document.documentElement.style.setProperty(
'--app-accent',
formatCss(oklchColor)
)
// Calculate text color based on background brightness or use default based on theme
let textColor: string
if (isDefaultColorAccent(color)) {
// If using default background, use default text color based on theme
textColor = '#FFF'
} else {
// Otherwise calculate based on brightness
textColor = getBrightness(finalColorAccent) > 128 ? '#000' : '#FFF'
}
// Update CSS variable for text color
document.documentElement.style.setProperty(
'--app-accent-fg',
textColor
)
// Store the original RGBA and calculated text color in state
set({
appAccentBgColor: finalColorAccent,
appAccentTextColor: textColor,
})
},
setAppDestructiveBgColor: (color: RgbaColor) => {
// Get the current theme state
const { isDark } = useTheme.getState()
// If color is being set to default, use theme-appropriate default
let finalColorDestructive = color
if (isDefaultColorDestructive(color)) {
finalColorDestructive = isDark
? defaultAppDestructiveBgColor
: defaultLightAppDestructiveBgColor
}
// Convert RGBA to a format culori can work with
const culoriRgb = rgb({
mode: 'rgb',
r: finalColorDestructive.r / 255,
g: finalColorDestructive.g / 255,
b: finalColorDestructive.b / 255,
alpha: finalColorDestructive.a,
})
// Convert to OKLCH for CSS variable
const oklchColor = oklch(culoriRgb)
// Update CSS variable with OKLCH
document.documentElement.style.setProperty(
'--app-destructive',
formatCss(oklchColor)
)
// Calculate text color based on background brightness or use default based on theme
let textColor: string
if (isDefaultColorDestructive(color)) {
// If using default background, use default text color based on theme
textColor = '#FFF'
} else {
// Otherwise calculate based on brightness
textColor =
getBrightness(finalColorDestructive) > 128 ? '#000' : '#FFF'
}
// Update CSS variable for text color
document.documentElement.style.setProperty(
'--app-destructive-fg',
textColor
)
// Store the original RGBA and calculated text color in state
set({
appDestructiveBgColor: finalColorDestructive,
appDestructiveTextColor: textColor,
})
},
}
},
{
name: localStorageKey.settingAppearance,
storage: createJSONStorage(() => localStorage),
// Apply settings when hydrating from storage
onRehydrateStorage: () => (state) => {
if (state) {
// Apply font size from storage
document.documentElement.style.setProperty(
'--font-size-base',
state.fontSize
)
// Get the current theme state
const { isDark } = useTheme.getState()
// If stored color is default, use theme-appropriate default
let finalColor = state.appBgColor
if (isDefaultColor(state.appBgColor)) {
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
}
let finalColorMainView = state.appMainViewBgColor
if (isDefaultColorMainView(state.appMainViewBgColor)) {
finalColorMainView = isDark
? defaultAppMainViewBgColor
: defaultLightAppMainViewBgColor
}
let finalColorPrimary = state.appPrimaryBgColor
if (isDefaultColorPrimary(state.appPrimaryBgColor)) {
finalColorPrimary = isDark
? defaultAppPrimaryBgColor
: defaultLightAppPrimaryBgColor
}
let finalColorAccent = state.appAccentBgColor
if (isDefaultColorAccent(state.appAccentBgColor)) {
finalColorAccent = isDark
? defaultAppAccentBgColor
: defaultLightAppAccentBgColor
}
let finalColorDestructive = state.appDestructiveBgColor
if (isDefaultColorDestructive(state.appDestructiveBgColor)) {
finalColorDestructive = isDark
? defaultAppDestructiveBgColor
: defaultLightAppDestructiveBgColor
}
// Apply app background color from storage
// Convert RGBA to a format culori can work with
const culoriRgb = rgb({
mode: 'rgb',
r: finalColor.r / 255,
g: finalColor.g / 255,
b: finalColor.b / 255,
alpha: finalColor.a,
})
const culoriRgbMainViewColor = rgb({
mode: 'rgb',
r: finalColorMainView.r / 255,
g: finalColorMainView.g / 255,
b: finalColorMainView.b / 255,
alpha: finalColorMainView.a,
})
const culoriRgbPrimaryColor = rgb({
mode: 'rgb',
r: finalColorPrimary.r / 255,
g: finalColorPrimary.g / 255,
b: finalColorPrimary.b / 255,
alpha: finalColorPrimary.a,
})
const culoriRgbAccentColor = rgb({
mode: 'rgb',
r: finalColorAccent.r / 255,
g: finalColorAccent.g / 255,
b: finalColorAccent.b / 255,
alpha: finalColorAccent.a,
})
const culoriRgbDestructiveColor = rgb({
mode: 'rgb',
r: finalColorDestructive.r / 255,
g: finalColorDestructive.g / 255,
b: finalColorDestructive.b / 255,
alpha: finalColorDestructive.a,
})
// Convert to OKLCH for CSS variable
const oklchColor = oklch(culoriRgb)
const oklchMainViewColor = oklch(culoriRgbMainViewColor)
const oklchPrimaryColor = oklch(culoriRgbPrimaryColor)
const oklchAccentColor = oklch(culoriRgbAccentColor)
const oklchDestructiveColor = oklch(culoriRgbDestructiveColor)
document.documentElement.style.setProperty(
'--app-bg',
formatCss(oklchColor)
)
document.documentElement.style.setProperty(
'--app-main-view',
formatCss(oklchMainViewColor)
)
document.documentElement.style.setProperty(
'--app-primary',
formatCss(oklchPrimaryColor)
)
document.documentElement.style.setProperty(
'--app-accent',
formatCss(oklchAccentColor)
)
document.documentElement.style.setProperty(
'--app-destructive',
formatCss(oklchDestructiveColor)
)
// Calculate and apply text color
let textColor: string
if (isDefaultColor(state.appBgColor)) {
// If using default background, use default text color based on theme
textColor = getDefaultTextColor(isDark)
} else {
// Otherwise calculate based on brightness
textColor = getBrightness(finalColor) > 128 ? '#000' : '#FFF'
}
document.documentElement.style.setProperty(
'--app-left-panel-fg',
textColor
)
// Calculate and apply text color for main view
let textColorMainView: string
if (isDefaultColorMainView(state.appMainViewBgColor)) {
// If using default background, use default text color based on theme
textColorMainView = getDefaultTextColor(isDark)
} else {
// Otherwise calculate based on brightness
textColorMainView =
getBrightness(finalColorMainView) > 128 ? '#000' : '#FFF'
}
document.documentElement.style.setProperty(
'--app-main-view-fg',
textColorMainView
)
// Calculate and apply text color for primary
let textColorPrimary: string
if (isDefaultColorPrimary(state.appPrimaryBgColor)) {
// If using default background, use default text color based on theme
textColorPrimary = getDefaultTextColor(isDark)
} else {
// Otherwise calculate based on brightness
textColorPrimary =
getBrightness(finalColorPrimary) > 128 ? '#000' : '#FFF'
}
document.documentElement.style.setProperty(
'--app-primary-fg',
textColorPrimary
)
// Calculate and apply text color for accent
let textColorAccent: string
if (isDefaultColorAccent(state.appAccentBgColor)) {
// If using default background, use default text color based on theme
textColorAccent = getDefaultTextColor(isDark)
} else {
// Otherwise calculate based on brightness
textColorAccent =
getBrightness(finalColorAccent) > 128 ? '#000' : '#FFF'
}
document.documentElement.style.setProperty(
'--app-accent-fg',
textColorAccent
)
// We don't need to update the state here as it will be handled by the store
// The state will be updated with the hydrated values automatically
}
// Return the state to be used for hydration
return state
},
}
)
)