195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { oklch, formatHex, inGamut, parse, type Oklch, rgb } from "culori";
|
|
import { Slider } from "./Slider";
|
|
import { displaySupportsP3, roundOklch } from "./utils";
|
|
|
|
export type LchChannel = "l" | "c" | "h";
|
|
|
|
export const toOklchString = (color: Oklch) => {
|
|
return `oklch(${color.l} ${color.c} ${color.h})`;
|
|
};
|
|
|
|
export type i18nKeys = LchChannel | "fallback" | "unsupported";
|
|
|
|
export type I18nProvider = (key: i18nKeys) => string;
|
|
|
|
interface PickerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
selectedColor: Oklch;
|
|
onColorChange: (value: Oklch) => void;
|
|
useP3: boolean;
|
|
i18n: I18nProvider;
|
|
}
|
|
|
|
const Preview = ({ color, i18n }: { color: Oklch; i18n: I18nProvider }) => {
|
|
const supportsP3 = displaySupportsP3();
|
|
const outOfRgb = !inGamut("rgb")(color);
|
|
const outOfP3 = !inGamut("p3")(color);
|
|
if (outOfP3 || (outOfRgb && !supportsP3)) {
|
|
const rgbColor = rgb(color);
|
|
const hex = formatHex(rgbColor);
|
|
const fallbackColor = supportsP3 ? hex : toOklchString(color);
|
|
return (
|
|
<div className="flex gap-1">
|
|
<div
|
|
className="w-full h-20 mb-5 rounded-lg flex items-center justify-center
|
|
border border-dashed border-gray-300 dark:border-gray-500"
|
|
>
|
|
<span className="text-gray-700 dark:text-zinc-300 text-sm text-center">{i18n("unsupported")}</span>
|
|
</div>
|
|
<div
|
|
className="w-full h-20 mb-5 rounded-lg flex items-end justify-center"
|
|
style={{ backgroundColor: fallbackColor }}
|
|
>
|
|
<span className="text-sm mb-1 bg-black/70 text-white px-2 rounded-xl">{i18n("fallback")}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
} else if (outOfRgb && supportsP3) {
|
|
const rgbColor = rgb(color);
|
|
const hex = formatHex(rgbColor);
|
|
return (
|
|
<div className="flex gap-1">
|
|
<div
|
|
className="w-full h-20 mb-5 rounded-lg flex items-end justify-center"
|
|
style={{ backgroundColor: toOklchString(color) }}
|
|
>
|
|
<span className="text-sm mb-1 bg-black/70 text-white px-2 rounded-xl">P3</span>
|
|
</div>
|
|
<div
|
|
className="w-full h-20 mb-5 rounded-lg flex items-end justify-center"
|
|
style={{ backgroundColor: hex }}
|
|
>
|
|
<span className="text-sm mb-1 bg-black/70 text-white px-2 rounded-xl">{i18n("fallback")}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
className="w-full h-20 mb-5 rounded-lg flex items-center justify-center"
|
|
style={{ backgroundColor: toOklchString(color) }}
|
|
></div>
|
|
);
|
|
};
|
|
|
|
export const Picker = ({ useP3, i18n, selectedColor, onColorChange, ...rest }: PickerProps) => {
|
|
const [displayColor, setDisplayColor] = useState<Oklch>(selectedColor);
|
|
const [hexText, setHexText] = useState(formatHex(selectedColor));
|
|
const [oklchText, setOklchText] = useState(toOklchString(selectedColor));
|
|
|
|
useEffect(() => {
|
|
try {
|
|
setHexText(formatHex(selectedColor));
|
|
setOklchText(toOklchString(selectedColor));
|
|
setDisplayColor(selectedColor);
|
|
} catch (error) {
|
|
console.warn("Invalid color combination");
|
|
}
|
|
}, [selectedColor]);
|
|
|
|
const handleChange = (channel: LchChannel, value: number) => {
|
|
setDisplayColor((prev) => ({ ...prev, [channel]: value }));
|
|
onColorChange({ ...selectedColor, [channel]: value });
|
|
};
|
|
|
|
const generateChangeHandler = (channel: LchChannel) => {
|
|
return (value: number) => {
|
|
handleChange(channel, value);
|
|
};
|
|
};
|
|
|
|
const handleHexChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const hex = e.target.value;
|
|
const color = parse(hex);
|
|
const oklchColor = oklch(rgb(color));
|
|
if (oklchColor) {
|
|
setDisplayColor(roundOklch(oklchColor));
|
|
}
|
|
setHexText(hex);
|
|
};
|
|
|
|
const handleHexInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const hex = e.target.value;
|
|
const color = parse(hex);
|
|
const oklchColor = oklch(rgb(color));
|
|
if (oklchColor) {
|
|
onColorChange(roundOklch(oklchColor));
|
|
setHexText(hex);
|
|
} else {
|
|
setHexText(formatHex(selectedColor));
|
|
}
|
|
};
|
|
|
|
const handleOklchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const oklchString = e.target.value;
|
|
const color = parse(oklchString);
|
|
const oklchColor = oklch(color);
|
|
if (oklchColor) {
|
|
setDisplayColor(roundOklch(oklchColor));
|
|
}
|
|
setOklchText(oklchString);
|
|
};
|
|
|
|
const handleOklchInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const oklchString = e.target.value;
|
|
const oklchColor = oklch(parse(oklchString));
|
|
if (oklchColor) {
|
|
onColorChange(roundOklch(oklchColor));
|
|
setOklchText(oklchString);
|
|
} else {
|
|
setOklchText(toOklchString(selectedColor));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div {...rest}>
|
|
<Preview color={displayColor} i18n={i18n} />
|
|
|
|
<div className="flex flex-col">
|
|
<input
|
|
className="mb-5 font-mono bg-zinc-200 dark:bg-zinc-700 h-10 px-3 rounded-xl focus:outline-none
|
|
focus:ring focus:ring-2 focus:ring-black dark:focus:ring-white"
|
|
type="text"
|
|
value={hexText}
|
|
onChange={handleHexChange}
|
|
onBlur={handleHexInput}
|
|
id="colorHex"
|
|
/>
|
|
<input
|
|
className="mb-5 font-mono bg-zinc-200 dark:bg-zinc-700 h-10 px-3 rounded-xl focus:outline-none
|
|
focus:ring focus:ring-2 focus:ring-black dark:focus:ring-white"
|
|
type="text"
|
|
value={oklchText}
|
|
onChange={handleOklchChange}
|
|
onBlur={handleOklchInput}
|
|
id="colorOklch"
|
|
/>
|
|
</div>
|
|
|
|
<Slider
|
|
channel="l"
|
|
color={displayColor}
|
|
onChange={generateChangeHandler("l")}
|
|
i18nProvider={i18n}
|
|
useP3={useP3}
|
|
/>
|
|
|
|
<Slider
|
|
channel="c"
|
|
color={displayColor}
|
|
onChange={generateChangeHandler("c")}
|
|
i18nProvider={i18n}
|
|
useP3={useP3}
|
|
/>
|
|
|
|
<Slider
|
|
channel="h"
|
|
color={displayColor}
|
|
onChange={generateChangeHandler("h")}
|
|
i18nProvider={i18n}
|
|
useP3={useP3}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|