diff --git a/src/components/color-theme/ColorThemePopoverButton.tsx b/src/components/color-theme/ColorThemePopoverButton.tsx index 8946d349..5dc0b96c 100644 --- a/src/components/color-theme/ColorThemePopoverButton.tsx +++ b/src/components/color-theme/ColorThemePopoverButton.tsx @@ -4,12 +4,13 @@ import { useMutation } from "@tanstack/react-query"; import clsx from "clsx"; import { LaptopIcon, MoonIcon, PaletteIcon, SunIcon } from "lucide-react"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ResponsiveButton } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { ResponsiveDropdown } from "../ui/responsive-dropdown"; import { Slider } from "../ui/slider"; import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; +import { Input } from "../ui/input"; import { EnableCustomVideoPlayerToggle } from "./EnableCustomVideoPlayerToggle"; import { ShowShortcutsToggle } from "./ShowShortcutsToggle"; import { ShowArticleStyleToggle } from "./ShowArticleStyleToggle"; @@ -17,6 +18,10 @@ import type React from "react"; import { authClient } from "~/lib/auth-client"; import { orpc } from "~/lib/orpc"; +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + function getCssVariable(name: string) { const value = window .getComputedStyle(document.documentElement) @@ -31,21 +36,6 @@ function getCssVariable(name: string) { return numberValue; } -function FormSection({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
-

{label}

- {children} -
- ); -} - const themes = ["light", "dark"] as const; type Theme = (typeof themes)[number]; function getThemeOrDefault(theme: string | undefined): Theme { @@ -55,6 +45,50 @@ function getThemeOrDefault(theme: string | undefined): Theme { return themes[0]; } +function useDebouncedCssValue(options: { + cssVariable: string; + initialValue: number; + clamp: (value: number) => number; + format: (value: number) => string; + onCommit: (value: number) => void; +}) { + const [committed, setCommitted] = useState(options.initialValue); + const [local, setLocal] = useState(null); + const flushTimeoutRef = useRef | null>(null); + + const onChange = (value: number) => { + const clamped = options.clamp(value); + setLocal(clamped); + if (flushTimeoutRef.current) clearTimeout(flushTimeoutRef.current); + flushTimeoutRef.current = setTimeout(() => { + document.documentElement.style.setProperty( + options.cssVariable, + options.format(clamped), + ); + }, 50); + }; + + const commit = (value: number) => { + if (flushTimeoutRef.current) clearTimeout(flushTimeoutRef.current); + const clamped = options.clamp(value); + setCommitted(clamped); + setLocal(null); + document.documentElement.style.setProperty( + options.cssVariable, + options.format(clamped), + ); + options.onCommit(clamped); + }; + + return { + value: local ?? committed, + committed, + setCommitted, + onChange, + onCommit: commit, + }; +} + function EditColorsForm() { const { data } = authClient.useSession(); @@ -64,91 +98,122 @@ function EditColorsForm() { const { resolvedTheme } = useTheme(); - const [hue, setHue] = useState(0); - const [saturation, setSaturation] = useState(0); - const [lightness, setLightness] = useState(0); - - useEffect(() => { - setTimeout(() => { - setHue(getCssVariable(`--${resolvedTheme}-hue`)); - setSaturation(getCssVariable(`--${resolvedTheme}-sat`)); - setLightness(getCssVariable(`--${resolvedTheme}-lgt`)); - }, 0); - }, [resolvedTheme]); - - const updateCssVariable = - ( - name: string, - setter: React.Dispatch>, - kind: "number" | "percentage", - ) => - (values: number[]) => { - const value = values[0]; - if (value === undefined) return; - - setter(value); - - const formattedValue = kind === "number" ? value.toString() : `${value}%`; - document.documentElement.style.setProperty(name, formattedValue); - }; + const brightnessMin = resolvedTheme === "dark" ? 0 : 70; + const brightnessMax = resolvedTheme === "dark" ? 30 : 100; - const saveValuesToDatabase = () => { + const saveValuesToDatabase = (hsl: [number, number, number]) => { if (!data?.session.id) return; saveThemeHSLToDatabase({ theme: getThemeOrDefault(resolvedTheme), - hsl: [hue, saturation, lightness], + hsl, }); }; - const brightnessMin = resolvedTheme === "dark" ? 0 : 70; - const brightnessMax = resolvedTheme === "dark" ? 30 : 100; + const hue = useDebouncedCssValue({ + cssVariable: `--${resolvedTheme}-hue`, + initialValue: 0, + clamp: (v) => clamp(v, 0, 360), + format: (v) => v.toString(), + onCommit: (v) => + saveValuesToDatabase([v, saturation.committed, lightness.committed]), + }); + + const saturation = useDebouncedCssValue({ + cssVariable: `--${resolvedTheme}-sat`, + initialValue: 0, + clamp: (v) => clamp(v, 0, 100), + format: (v) => `${v}%`, + onCommit: (v) => + saveValuesToDatabase([hue.committed, v, lightness.committed]), + }); + + const lightness = useDebouncedCssValue({ + cssVariable: `--${resolvedTheme}-lgt`, + initialValue: 0, + clamp: (v) => clamp(v, brightnessMin, brightnessMax), + format: (v) => `${v}%`, + onCommit: (v) => + saveValuesToDatabase([hue.committed, saturation.committed, v]), + }); + + useEffect(() => { + setTimeout(() => { + hue.setCommitted(getCssVariable(`--${resolvedTheme}-hue`)); + saturation.setCommitted(getCssVariable(`--${resolvedTheme}-sat`)); + lightness.setCommitted(getCssVariable(`--${resolvedTheme}-lgt`)); + }, 0); + }, [hue, lightness, resolvedTheme, saturation]); return (
- -
- +
+

Hue

+ hue.onChange(parseInt(e.target.value) || 0)} + onBlur={(e) => hue.onCommit(parseInt(e.target.value) || 0)} />
- - hue.onChange(values[0] ?? hue.committed)} + onValueCommit={(values) => hue.onCommit(values[0] ?? hue.committed)} + /> +
+
+
+

Saturation

+ saturation.onChange(parseInt(e.target.value) || 0)} + onBlur={(e) => saturation.onCommit(parseInt(e.target.value) || 0)} + /> +
+ + saturation.onChange(values[0] ?? saturation.committed) + } + onValueCommit={(values) => + saturation.onCommit(values[0] ?? saturation.committed) + } /> - - +
+
+
+

Lightness

+ lightness.onChange(parseInt(e.target.value) || 0)} + onBlur={(e) => lightness.onCommit(parseInt(e.target.value) || 0)} + /> +
+ lightness.onChange(values[0] ?? lightness.committed) + } + onValueCommit={(values) => + lightness.onCommit(values[0] ?? lightness.committed) + } /> - +
); }