Feature/hsl (#62)

* working hsl adjuster

* refactor

* format to hex
This commit is contained in:
Sahaj Jain
2025-05-08 23:44:32 +05:30
committed by GitHub
parent 385bf5459c
commit f82f2e55fc
12 changed files with 269 additions and 133 deletions
@@ -28,28 +28,22 @@ export function ActionBarButtons({
onShareClick,
isSaving,
}: ActionBarButtonsProps) {
const { themeState, restoreThemeCheckpoint, hasThemeChangedFromCheckpoint } =
useEditorStore();
const { themeState, resetToCurrentPreset, hasThemeChangedFromCheckpoint } = useEditorStore();
const { getPreset } = useThemePresetStore();
const currentPreset = themeState?.preset
? getPreset(themeState?.preset)
: undefined;
const currentPreset = themeState?.preset ? getPreset(themeState?.preset) : undefined;
const isSavedPreset = !!currentPreset && currentPreset.source === "SAVED";
return (
<div className="flex items-center gap-1">
<MoreOptions />
<Separator orientation="vertical" className="h-8 mx-1" />
<Separator orientation="vertical" className="mx-1 h-8" />
<ThemeToggle />
<Separator orientation="vertical" className="h-8 mx-1" />
<ResetButton
onReset={restoreThemeCheckpoint}
isDisabled={!hasThemeChangedFromCheckpoint()}
/>
<Separator orientation="vertical" className="mx-1 h-8" />
<ResetButton onReset={resetToCurrentPreset} isDisabled={!hasThemeChangedFromCheckpoint()} />
<ImportButton onImportClick={onImportClick} />
<AIGenerateButton onClick={onAiGenerateClick} />
<Separator orientation="vertical" className="h-8 mx-1" />
<Separator orientation="vertical" className="mx-1 h-8" />
{isSavedPreset && <EditButton themeId={themeState.preset as string} />}
<ShareButton onShareClick={() => onShareClick(themeState.preset)} />
<SaveButton onSaveClick={onSaveClick} isSaving={isSaving} />
+18 -24
View File
@@ -1,11 +1,7 @@
"use client";
import React, { useEffect, use } from "react";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EditorConfig } from "@/types/editor";
import { Theme, ThemeStyles } from "@/types/theme";
@@ -33,15 +29,10 @@ const isThemeStyles = (styles: unknown): styles is ThemeStyles => {
const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
const themeState = useEditorStore((state) => state.themeState);
const setThemeState = useEditorStore((state) => state.setThemeState);
const saveThemeCheckpoint = useEditorStore(
(state) => state.saveThemeCheckpoint
);
const Controls = config.controls;
const Preview = config.preview;
const loadSavedPresets = useThemePresetStore(
(state) => state.loadSavedPresets
);
const loadSavedPresets = useThemePresetStore((state) => state.loadSavedPresets);
const { data: session } = authClient.useSession();
@@ -64,14 +55,17 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
useEffect(() => {
if (initialTheme && isThemeStyles(initialTheme.styles)) {
const prev = useEditorStore.getState().themeState;
setThemeState({ ...prev, styles: initialTheme.styles });
saveThemeCheckpoint();
setThemeState({
...prev,
styles: initialTheme.styles,
preset: initialTheme.id,
});
}
}, [initialTheme, setThemeState, saveThemeCheckpoint]);
}, [initialTheme, setThemeState]);
if (initialTheme && !isThemeStyles(initialTheme.styles)) {
return (
<div className="flex justify-center items-center h-full text-destructive">
<div className="text-destructive flex h-full items-center justify-center">
Fetched theme data is invalid.
</div>
);
@@ -82,10 +76,10 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
return (
<div className="h-full overflow-hidden">
{/* Desktop Layout */}
<div className="h-full hidden md:block">
<div className="hidden h-full md:block">
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={30} minSize={20} maxSize={40}>
<div className="h-full flex flex-col">
<div className="flex h-full flex-col">
<Controls
styles={styles}
onChange={handleStyleChange}
@@ -96,8 +90,8 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={70} minSize={20}>
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex h-full flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<ActionBar />
<Preview styles={styles} currentMode={themeState.currentMode} />
</div>
@@ -111,15 +105,15 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
<Tabs defaultValue="controls" className="h-full">
<TabsList className="w-full rounded-none">
<TabsTrigger value="controls" className="flex-1">
<Sliders className="h-4 w-4 mr-2" />
<Sliders className="mr-2 h-4 w-4" />
Controls
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">
Preview
</TabsTrigger>
</TabsList>
<TabsContent value="controls" className="h-[calc(100%-2.5rem)] mt-0">
<div className="h-full flex flex-col">
<TabsContent value="controls" className="mt-0 h-[calc(100%-2.5rem)]">
<div className="flex h-full flex-col">
<Controls
styles={styles}
onChange={handleStyleChange}
@@ -128,8 +122,8 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
/>
</div>
</TabsContent>
<TabsContent value="preview" className="h-[calc(100%-2.5rem)] mt-0">
<div className="h-full flex flex-col">
<TabsContent value="preview" className="mt-0 h-[calc(100%-2.5rem)]">
<div className="flex h-full flex-col">
<ActionBar />
<Preview styles={styles} currentMode={themeState.currentMode} />
</div>
@@ -0,0 +1,158 @@
"use client";
import React, { useCallback, useMemo, useRef, useEffect } from "react";
import { SliderWithInput } from "./slider-with-input";
import { useEditorStore } from "../../store/editor-store";
import { COMMON_STYLES, defaultThemeState } from "../../config/theme";
import { ThemeEditorState } from "@/types/editor";
import { converter, formatHex, Hsl } from "culori";
import { debounce } from "@/utils/debounce";
import { isDeepEqual } from "@/lib/utils";
// Adjusts a color by modifying HSL values
function adjustColorByHsl(
color: string,
hueShift: number,
saturationScale: number,
lightnessScale: number
): string {
const hsl = converter("hsl")(color);
const h = hsl?.h;
const s = hsl?.s;
const l = hsl?.l;
if (h === undefined || s === undefined || l === undefined) {
return color;
}
const adjustedHsl = {
h: (((h + hueShift) % 360) + 360) % 360,
s: Math.min(1, Math.max(0, s * saturationScale)),
l: Math.min(1, Math.max(0.1, l * lightnessScale)),
};
const out = converter("hsl")(adjustedHsl as Hsl);
return formatHex(out);
}
const HslAdjustmentControls = () => {
const { themeState, setThemeState, saveThemeCheckpoint, themeCheckpoint } = useEditorStore();
const debouncedUpdateRef = useRef<ReturnType<typeof debounce> | null>(null);
// Get current HSL adjustments with fallback to defaults
const currentHslAdjustments = useMemo(
() => themeState.hslAdjustments ?? defaultThemeState.hslAdjustments!,
[themeState.hslAdjustments]
);
// Save checkpoint if HSL adjustments are at default values
useEffect(() => {
if (isDeepEqual(themeState.hslAdjustments, defaultThemeState.hslAdjustments)) {
saveThemeCheckpoint();
}
}, [themeState.hslAdjustments, saveThemeCheckpoint]);
// Setup debounced update function
useEffect(() => {
debouncedUpdateRef.current = debounce((hslAdjustments: ThemeEditorState["hslAdjustments"]) => {
const {
hueShift = defaultThemeState.hslAdjustments!.hueShift,
saturationScale = defaultThemeState.hslAdjustments!.saturationScale,
lightnessScale = defaultThemeState.hslAdjustments!.lightnessScale,
} = hslAdjustments ?? {};
const adjustments = { hueShift, saturationScale, lightnessScale };
const state = themeCheckpoint ?? themeState;
const { light: lightStyles, dark: darkStyles } = state.styles;
const updatedLightStyles = Object.keys(lightStyles)
.filter((key) => !COMMON_STYLES.includes(key))
.reduce<Record<string, string>>((acc, key) => {
const colorKey = key as keyof typeof lightStyles;
return {
...acc,
[key]: adjustColorByHsl(
lightStyles[colorKey],
adjustments.hueShift,
adjustments.saturationScale,
adjustments.lightnessScale
),
};
}, {});
const updatedDarkStyles = Object.keys(darkStyles)
.filter((key) => !COMMON_STYLES.includes(key))
.reduce<Record<string, string>>((acc, key) => {
const colorKey = key as keyof typeof darkStyles;
return {
...acc,
[key]: adjustColorByHsl(
darkStyles[colorKey],
adjustments.hueShift,
adjustments.saturationScale,
adjustments.lightnessScale
),
};
}, {});
// Update theme state with all changes
setThemeState({
...state,
hslAdjustments,
styles: {
light: { ...lightStyles, ...updatedLightStyles },
dark: { ...darkStyles, ...updatedDarkStyles },
},
});
}, 10);
return () => debouncedUpdateRef.current?.cancel();
}, [themeState, setThemeState, themeCheckpoint]);
// Handle HSL value changes
const handleHslChange = useCallback(
(property: keyof typeof currentHslAdjustments, value: number) => {
if (debouncedUpdateRef.current) {
debouncedUpdateRef.current({
...currentHslAdjustments,
[property]: value,
});
}
},
[currentHslAdjustments]
);
return (
<>
<SliderWithInput
value={currentHslAdjustments.hueShift}
onChange={(value) => handleHslChange("hueShift", value)}
unit="deg"
min={-180}
max={180}
step={1}
label="Hue Shift"
/>
<SliderWithInput
value={currentHslAdjustments.saturationScale}
onChange={(value) => handleHslChange("saturationScale", value)}
unit="x"
min={0}
max={2}
step={0.01}
label="Saturation Multiplier"
/>
<SliderWithInput
value={currentHslAdjustments.lightnessScale}
onChange={(value) => handleHslChange("lightnessScale", value)}
unit="x"
min={0.2}
max={2}
step={0.01}
label="Lightness Multiplier"
/>
</>
);
};
export default HslAdjustmentControls;
+29 -54
View File
@@ -30,6 +30,7 @@ import ShadowControl from "./shadow-control";
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
import ThemeEditActions from "./theme-edit-actions";
import { useThemePresetStore } from "@/store/theme-preset-store";
import HslAdjustmentControls from "./hsl-adjustment-controls";
const ThemeControlPanel = ({
styles,
@@ -49,10 +50,7 @@ const ThemeControlPanel = ({
);
const updateStyle = React.useCallback(
<K extends keyof typeof currentStyles>(
key: K,
value: (typeof currentStyles)[K]
) => {
<K extends keyof typeof currentStyles>(key: K, value: (typeof currentStyles)[K]) => {
// apply common styles to both light and dark modes
if (COMMON_STYLES.includes(key)) {
onChange({
@@ -96,26 +94,19 @@ const ThemeControlPanel = ({
<ThemeEditActions theme={theme} />
)}
</div>
<div className="space-y-4 min-h-0 flex-1 flex flex-col">
<Tabs
defaultValue="colors"
className="w-full flex-1 flex flex-col min-h-0"
>
<div className="px-4 mt-2">
<TabsList className="inline-flex w-fit items-center justify-center rounded-full bg-background px-0 text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col space-y-4">
<Tabs defaultValue="colors" className="flex min-h-0 w-full flex-1 flex-col">
<div className="mt-2 px-4">
<TabsList className="bg-background text-muted-foreground inline-flex w-fit items-center justify-center rounded-full px-0">
<TabsTriggerPill value="colors">Colors</TabsTriggerPill>
<TabsTriggerPill value="typography">Typography</TabsTriggerPill>
<TabsTriggerPill value="other">Other</TabsTriggerPill>
</TabsList>
</div>
<ScrollArea className="h-full pb-40 p-4 pt-0 flex-1">
<ScrollArea className="h-full flex-1 p-4 pt-0 pb-40">
<TabsContent value="colors">
<ControlSection
title="Primary Colors"
id="primary-colors"
expanded
>
<ControlSection title="Primary Colors" id="primary-colors" expanded>
<ColorPicker
color={currentStyles.primary}
onChange={(color) => updateStyle("primary", color)}
@@ -136,9 +127,7 @@ const ThemeControlPanel = ({
/>
<ColorPicker
color={currentStyles["secondary-foreground"]}
onChange={(color) =>
updateStyle("secondary-foreground", color)
}
onChange={(color) => updateStyle("secondary-foreground", color)}
label="Secondary Foreground"
/>
</ControlSection>
@@ -216,9 +205,7 @@ const ThemeControlPanel = ({
/>
<ColorPicker
color={currentStyles["destructive-foreground"]}
onChange={(color) =>
updateStyle("destructive-foreground", color)
}
onChange={(color) => updateStyle("destructive-foreground", color)}
label="Destructive Foreground"
/>
</ControlSection>
@@ -287,9 +274,7 @@ const ThemeControlPanel = ({
/>
<ColorPicker
color={currentStyles["sidebar-primary-foreground"]}
onChange={(color) =>
updateStyle("sidebar-primary-foreground", color)
}
onChange={(color) => updateStyle("sidebar-primary-foreground", color)}
label="Sidebar Primary Foreground"
/>
<ColorPicker
@@ -299,9 +284,7 @@ const ThemeControlPanel = ({
/>
<ColorPicker
color={currentStyles["sidebar-accent-foreground"]}
onChange={(color) =>
updateStyle("sidebar-accent-foreground", color)
}
onChange={(color) => updateStyle("sidebar-accent-foreground", color)}
label="Sidebar Accent Foreground"
/>
<ColorPicker
@@ -318,16 +301,16 @@ const ThemeControlPanel = ({
</TabsContent>
<TabsContent value="typography" className="flex flex-col gap-4">
<div className="p-3 bg-muted/50 rounded-md border mb-2 flex items-start gap-2.5">
<AlertCircle className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="text-sm text-muted-foreground">
<div className="bg-muted/50 mb-2 flex items-start gap-2.5 rounded-md border p-3">
<AlertCircle className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="text-muted-foreground text-sm">
<p>
To use custom fonts, embed them in your project. <br />
See{" "}
<a
href="https://tailwindcss.com/docs/font-family"
target="_blank"
className="underline underline-offset-2 hover:text-muted-foreground/90"
className="hover:text-muted-foreground/90 underline underline-offset-2"
>
Tailwind docs
</a>{" "}
@@ -338,7 +321,7 @@ const ThemeControlPanel = ({
<ControlSection title="Font Family" expanded>
<div className="mb-4">
<Label htmlFor="font-sans" className="text-xs mb-1.5 block">
<Label htmlFor="font-sans" className="mb-1.5 block text-xs">
Sans-Serif Font
</Label>
<ThemeFontSelect
@@ -352,7 +335,7 @@ const ThemeControlPanel = ({
<Separator className="my-4" />
<div className="mb-4">
<Label htmlFor="font-serif" className="text-xs mb-1.5 block">
<Label htmlFor="font-serif" className="mb-1.5 block text-xs">
Serif Font
</Label>
<ThemeFontSelect
@@ -365,7 +348,7 @@ const ThemeControlPanel = ({
<Separator className="my-4" />
<div>
<Label htmlFor="font-mono" className="text-xs mb-1.5 block">
<Label htmlFor="font-mono" className="mb-1.5 block text-xs">
Monospace Font
</Label>
<ThemeFontSelect
@@ -379,12 +362,8 @@ const ThemeControlPanel = ({
<ControlSection title="Letter Spacing" expanded>
<SliderWithInput
value={parseFloat(
currentStyles["letter-spacing"]?.replace("em", "")
)}
onChange={(value) =>
updateStyle("letter-spacing", `${value}em`)
}
value={parseFloat(currentStyles["letter-spacing"]?.replace("em", ""))}
onChange={(value) => updateStyle("letter-spacing", `${value}em`)}
min={-0.5}
max={0.5}
step={0.025}
@@ -395,6 +374,10 @@ const ThemeControlPanel = ({
</TabsContent>
<TabsContent value="other">
<ControlSection title="HSL Adjustments" expanded>
<HslAdjustmentControls />
</ControlSection>
<ControlSection title="Radius" expanded>
<SliderWithInput
value={radius}
@@ -422,18 +405,10 @@ const ThemeControlPanel = ({
<ShadowControl
shadowColor={currentStyles["shadow-color"]}
shadowOpacity={parseFloat(currentStyles["shadow-opacity"])}
shadowBlur={parseFloat(
currentStyles["shadow-blur"]?.replace("px", "")
)}
shadowSpread={parseFloat(
currentStyles["shadow-spread"]?.replace("px", "")
)}
shadowOffsetX={parseFloat(
currentStyles["shadow-offset-x"]?.replace("px", "")
)}
shadowOffsetY={parseFloat(
currentStyles["shadow-offset-y"]?.replace("px", "")
)}
shadowBlur={parseFloat(currentStyles["shadow-blur"]?.replace("px", ""))}
shadowSpread={parseFloat(currentStyles["shadow-spread"]?.replace("px", ""))}
shadowOffsetX={parseFloat(currentStyles["shadow-offset-x"]?.replace("px", ""))}
shadowOffsetY={parseFloat(currentStyles["shadow-offset-y"]?.replace("px", ""))}
onChange={(key, value) => {
if (key === "shadow-color") {
updateStyle(key, value as string);