27
loading...
This website collects cookies to deliver better user experience
color
- the current color selectedcolors
- the array of predefined colours for the colour paletteonChange
- the handler when a new colour is selectedvariant
- the type of selector, predefined or free
// ColorPicker.tsx
export enum ColorPickerVariant {
Predefined = "predefined",
Free = "free"
}
interface ColorPickerProps {
color: string;
colors: Array<string>;
onChange(color: string): void;
variant: ColorPickerVariant;
}
export const ColorPicker = (props: ColorPickerProps) => {
const { color, colors, onChange, variant } = props;
...
}
color
, colors
and onChange
props defined above to populate the component and to handle any colour selections made by the user.// PredefinedSelector.tsx
interface PredefinedSelectorProps {
color: string;
colors: Array<string>;
onSelect(color: string): void;
}
export const PredefinedSelector = (props: PredefinedSelectorProps) => {
const { color, colors, onSelect } = props;
...
}
// FreeSelector.tsx
interface FreeSelectorProps {
color: string; // we'll need to convert this to HSV
satCoords: Array<number>; // [x, y] coordinates for saturation map
hueCoords: number; // x coordinates for hue map
onSaturationChange: MouseEventHandler;
onHueChange: MouseEventHandler;
}
export const FreeSelector = (props: FreeSelectorProps) => {
const {
color,
satCoords,
hueCoords,
onSaturationChange,
onHueChange
} = props;
...
}
// FreeSelector.css
...
.cp-saturation {
width: 100%;
height: 150px;
/* This provides a smooth representation
of brightness, which we overlay with an
inline background-color for saturation */
background-image: linear-gradient(transparent, black),
linear-gradient(to right, white, transparent);
border-radius: 4px;
/* This allows us to position an absolute
indicator over the map */
position: relative;
cursor: crosshair;
}
.cp-hue {
width: 100%;
height: 12px;
/* This covers the full range of hues */
background-image: linear-gradient(
to right,
#ff0000,
#ffff00,
#00ff00,
#00ffff,
#0000ff,
#ff00ff,
#ff0000
);
border-radius: 999px;
/* This allows us to position an absolute
indicator over the map */
position: relative;
cursor: crosshair;
}
...
// FreeSelector.tsx
import "./FreeSelector.css";
...
export const FreeSelector = (props: FreeSelectorProps) => {
...
return (
<div className="cp-free-root">
<div
className="cp-saturation"
style={{
backgroundColor: `hsl(${parsedColor.hsv.h}, 100%, 50%)`
}}
onClick={onSaturationChange}
>
// TODO: create an indicator to show current x,y position
</div>
<div className="cp-hue" onClick={onHueChange}>
// TODO: create an indicator to show current hue
</div>
</div>
);
};
color
string into the HSV representation. We will cover that later on. For now, we finish up the FreeSelector view. Here is the complete code for the FreeSelector.// FreeSelector.css
.cp-free-root {
display: grid;
grid-gap: 8px;
margin-bottom: 16px;
max-width: 100%;
width: 400px;
}
.cp-saturation {
width: 100%;
height: 150px;
background-image: linear-gradient(transparent, black),
linear-gradient(to right, white, transparent);
border-radius: 4px;
position: relative;
cursor: crosshair;
}
.cp-saturation-indicator {
width: 15px;
height: 15px;
border: 2px solid #ffffff;
border-radius: 50%;
transform: translate(-7.5px, -7.5px);
position: absolute;
}
.cp-hue {
width: 100%;
height: 12px;
background-image: linear-gradient(
to right,
#ff0000,
#ffff00,
#00ff00,
#00ffff,
#0000ff,
#ff00ff,
#ff0000
);
border-radius: 999px;
position: relative;
cursor: crosshair;
}
.cp-hue-indicator {
width: 15px;
height: 15px;
border: 2px solid #ffffff;
border-radius: 50%;
transform: translate(-7.5px, -2px);
position: absolute;
}
// FreeSelector.tsx
import React, { MouseEventHandler } from "react";
import { Color } from "../../Interfaces/Color";
import "./FreeSelector.css";
interface FreeSelectorProps {
parsedColor: Color;
satCoords: Array<number>;
hueCoords: number;
onSaturationChange: MouseEventHandler;
onHueChange: MouseEventHandler;
}
export const FreeSelector = (props: FreeSelectorProps) => {
const {
parsedColor,
satCoords,
hueCoords,
onSaturationChange,
onHueChange
} = props;
return (
<div className="cp-free-root">
<div
className="cp-saturation"
style={{
backgroundColor: `hsl(${parsedColor.hsv.h}, 100%, 50%)`
}}
onClick={onSaturationChange}
>
<div
className="cp-saturation-indicator"
style={{
backgroundColor: parsedColor.hex,
left: (satCoords?.[0] ?? 0) + "%",
top: (satCoords?.[1] ?? 0) + "%"
}}
/>
</div>
<div className="cp-hue" onClick={onHueChange}>
<div
className="cp-hue-indicator"
style={{
backgroundColor: parsedColor.hex,
left: (hueCoords ?? 0) + "%"
}}
/>
</div>
</div>
);
};
satCoords
and hueCoords
. These are used to position the indicators for the saturation map and hue map respectively. With the CSS properties position, left, and top, we can position the indicator accurately. Notice that we also use the transform property to adjust for the width and height of the indicator.// PredefinedSelector.css
.cp-predefined-root {
padding-bottom: 16px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
max-width: 100%;
min-width: 200px;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.cp-predefined-root::-webkit-scrollbar {
display: none;
}
.cp-color-button {
width: 37px;
padding: 5px;
border-radius: 4px;
background-color: inherit;
}
.cp-preview-color {
/* Shadow so we can see white against white */
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 2px 1px -1px rgba(0, 0, 0, 0.12);
width: 25px;
height: 25px;
border-radius: 50%;
}
// PredefinedSelector.tsx
import React from "react";
import { Color } from "../../Interfaces/Color";
import "./PredefinedSelector.css";
const predefinedRows = 3;
interface PredefinedSelectorProps {
parsedColor: Color;
colors: Array<string>;
onSelect(color: string): void;
}
export const PredefinedSelector = (props: PredefinedSelectorProps) => {
const { parsedColor, colors, onSelect } = props;
return (
<div
className="cp-predefined-root"
style={{
height: 2 + 35 * predefinedRows + "px",
width: 16 + 35 * Math.ceil(colors.length / predefinedRows) + "px"
}}
>
{colors.map((color) => (
<button
className="cp-color-button"
key={color}
onClick={(event) => onSelect(color)}
style={{
border: color === parsedColor?.hex ? "1px solid #000000" : "none"
}}
>
<div
className="cp-preview-color"
style={{
background: color
}}
/>
</button>
))}
</div>
);
};
colors
array to populate the palette with our predefined colours.// ColorPicker.css
.cp-container {
padding: 12px;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
width: fit-content;
}
.cp-container::-webkit-scrollbar {
display: none;
}
.cp-input-container {
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 2px;
}
.cp-input-group {
display: grid;
grid-template-columns: auto auto auto;
grid-gap: 8px;
align-items: center;
}
.cp-color-preview {
/* Shadow so we can see white against white */
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 2px 1px -1px rgba(0, 0, 0, 0.12);
width: 25px;
height: 25px;
border-radius: 50%;
}
input {
padding: 4px 6px;
}
label,
input {
display: block;
}
.cp-input-label {
font-size: 12px;
}
.cp-hex-input {
width: 60px;
}
.cp-rgb-input {
width: 30px;
}
// ColorPicker.tsx
export const ColorPicker = (props: ColorPickerProps) => {
...
return (
<div className="cp-container">
// TODO: add selectors
<div className="cp-input-container">
<div className="cp-input-group">
<div
className="cp-color-preview"
style={{
background: color
}}
/>
<div>
<label className="cp-input-label" htmlFor="cp-input-hex">
Hex
</label>
<input
id="cp-input-hex"
className="cp-hex-input"
placeholder="Hex"
value={parsedColor?.hex}
onChange={handleHexChange}
/>
</div>
</div>
<div className="cp-input-group">
<div>
<label className="cp-input-label" htmlFor="cp-input-r">
R
</label>
<input
id="cp-input-r"
className="cp-rgb-input"
placeholder="R"
value={parsedColor.rgb.r}
onChange={(event) => handleRgbChange("r", event.target.value)}
inputMode="numeric"
pattern="[0-9]*"
/>
</div>
<div>
<label className="cp-input-label" htmlFor="cp-input-g">
G
</label>
<input
id="cp-input-g"
className="cp-rgb-input"
placeholder="G"
value={parsedColor.rgb.g}
onChange={(event) => handleRgbChange("g", event.target.value)}
inputMode="numeric"
pattern="[0-9]*"
/>
</div>
<div>
<label className="cp-input-label" htmlFor="cp-input-b">
B
</label>
<input
id="cp-input-b"
className="cp-rgb-input"
placeholder="B"
value={parsedColor.rgb.b}
onChange={(event) => handleRgbChange("b", event.target.value)}
inputMode="numeric"
pattern="[0-9]*"
/>
</div>
</div>
</div>
</div>
);
};
Color
model and the conversion methods between the various colour representations. There are three colour representations that are important for our picker: Hex, RGB and HSV. We thus define the Color
model:// Color.ts
export interface Color {
hex: string;
rgb: ColorRGB;
hsv: ColorHSV;
}
export interface ColorRGB {
r: number;
g: number;
b: number;
}
export interface ColorHSV {
h: number;
s: number;
v: number;
}
// Converters.ts
import { ColorHSV, ColorRGB } from "../Interfaces/Color";
export function rgbToHex(color: ColorRGB): string {
var { r, g, b } = color;
var hexR = r.toString(16);
var hexG = g.toString(16);
var hexB = b.toString(16);
if (hexR.length === 1) hexR = "0" + r;
if (hexG.length === 1) hexG = "0" + g;
if (hexB.length === 1) hexB = "0" + b;
return "#" + hexR + hexG + hexB;
}
export function hexToRgb(color: string): ColorRGB {
var r = 0;
var g = 0;
var b = 0;
// 3 digits
if (color.length === 4) {
r = Number("0x" + color[1] + color[1]);
g = Number("0x" + color[2] + color[2]);
b = Number("0x" + color[3] + color[3]);
// 6 digits
} else if (color.length === 7) {
r = Number("0x" + color[1] + color[2]);
g = Number("0x" + color[3] + color[4]);
b = Number("0x" + color[5] + color[6]);
}
return {
r,
g,
b
};
}
export function rgbToHsv(color: ColorRGB): ColorHSV {
var { r, g, b } = color;
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const d = max - Math.min(r, g, b);
const h = d
? (max === r
? (g - b) / d + (g < b ? 6 : 0)
: max === g
? 2 + (b - r) / d
: 4 + (r - g) / d) * 60
: 0;
const s = max ? (d / max) * 100 : 0;
const v = max * 100;
return { h, s, v };
}
export function hsvToRgb(color: ColorHSV): ColorRGB {
var { h, s, v } = color;
s /= 100;
v /= 100;
const i = ~~(h / 60);
const f = h / 60 - i;
const p = v * (1 - s);
const q = v * (1 - s * f);
const t = v * (1 - s * (1 - f));
const index = i % 6;
const r = Math.round([v, q, p, p, t, v][index] * 255);
const g = Math.round([t, v, v, q, p, p][index] * 255);
const b = Math.round([p, p, t, v, v, q][index] * 255);
return {
r,
g,
b
};
}
parsedColor
object we were accessing earlier? We also need a method to convert a string representation of a colour into our Color
model.// ColorUtils.ts
import { Color, ColorRGB } from "../Interfaces/Color";
import { hexToRgb, rgbToHex, rgbToHsv } from "./Converters";
export function getRgb(color: string): ColorRGB {
const matches = /rgb\((\d+),\s?(\d+),\s?(\d+)\)/i.exec(color);
const r = Number(matches?.[1] ?? 0);
const g = Number(matches?.[2] ?? 0);
const b = Number(matches?.[3] ?? 0);
return {
r,
g,
b
};
}
export function parseColor(color: string): Color {
var hex = "";
var rgb = {
r: 0,
g: 0,
b: 0
};
var hsv = {
h: 0,
s: 0,
v: 0
};
if (color.slice(0, 1) === "#") {
hex = color;
rgb = hexToRgb(hex);
hsv = rgbToHsv(rgb);
} else if (color.slice(0, 3) === "rgb") {
rgb = getRgb(color);
hex = rgbToHex(rgb);
hsv = rgbToHsv(rgb);
}
return {
hex,
rgb,
hsv
};
}
export function getSaturationCoordinates(color: Color): [number, number] {
const { s, v } = rgbToHsv(color.rgb);
const x = s;
const y = 100 - v;
return [x, y];
}
export function getHueCoordinates(color: Color): number {
const { h } = color.hsv;
const x = (h / 360) * 100;
return x;
}
export function clamp(number: number, min: number, max: number): number {
if (!max) {
return Math.max(number, min) === min ? number : min;
} else if (Math.min(number, min) === number) {
return min;
} else if (Math.max(number, max) === number) {
return max;
}
return number;
}
getSaturationCoordinates
and getHueCoordinates
methods to position our indicators. If you notice, the HSV model maps very nicely into our linear gradients since s and v are percentages. Hue maps to a 360 degree circle, so we need to normalize the value for our linear scale.// ColorPicker.tsx
export const ColorPicker = (props: ColorPickerProps) => {
const { color, colors, onChange, variant } = props;
const parsedColor = useMemo(() => parseColor(color), [color]);
const satCoords = useMemo(() => getSaturationCoordinates(parsedColor), [
parsedColor
]);
const hueCoords = useMemo(() => getHueCoordinates(parsedColor), [
parsedColor
]);
const handleHexChange = useCallback(
(event) => {
var val = event.target.value;
if (val?.slice(0, 1) !== "#") {
val = "#" + val;
}
onChange(val);
},
[onChange]
);
const handleRgbChange = useCallback(
(component, value) => {
const { r, g, b } = parsedColor.rgb;
switch (component) {
case "r":
onChange(rgbToHex({ r: value ?? 0, g, b }));
return;
case "g":
onChange(rgbToHex({ r, g: value ?? 0, b }));
return;
case "b":
onChange(rgbToHex({ r, g, b: value ?? 0 }));
return;
default:
return;
}
},
[parsedColor, onChange]
);
const handleSaturationChange = useCallback(
(event) => {
const { width, height, left, top } = event.target.getBoundingClientRect();
const x = clamp(event.clientX - left, 0, width);
const y = clamp(event.clientY - top, 0, height);
const s = (x / width) * 100;
const v = 100 - (y / height) * 100;
const rgb = hsvToRgb({ h: parsedColor?.hsv.h, s, v });
onChange(rgbToHex(rgb));
},
[parsedColor, onChange]
);
const handleHueChange = useCallback(
(event) => {
const { width, left } = event.target.getBoundingClientRect();
const x = clamp(event.clientX - left, 0, width);
const h = Math.round((x / width) * 360);
const hsv = { h, s: parsedColor?.hsv.s, v: parsedColor?.hsv.v };
const rgb = hsvToRgb(hsv);
onChange(rgbToHex(rgb));
},
[parsedColor, onChange]
);
...
};
color
string received as prop. Once we get the parsedColor
, we can retrieve the satCoords
and hueCoords
using our getters. We then define the handlers for the change events in our selectors - handleHexChange
, handleRgbChange
, handleSaturationChange
and handleHueChange
. handleSaturationChange
and handleHueChange
are just the inverse functions of getSaturationCoordinates
and getHueCoordinates
.// ColorPicker.tsx
import React, { useCallback, useMemo } from "react";
import {
clamp,
DEFAULT_COLOR,
DEFAULT_COLORS,
getHueCoordinates,
getSaturationCoordinates,
hsvToRgb,
parseColor,
rgbToHex
} from "../Utils";
import "./ColorPicker.css";
import { FreeSelector, PredefinedSelector } from "./Options";
export enum ColorPickerVariant {
Predefined = "predefined",
Free = "free"
}
interface ColorPickerProps {
color: string;
colors: Array<string>;
onChange(color: string): void;
variant: ColorPickerVariant;
}
export const ColorPicker = (props: ColorPickerProps) => {
const { color, colors, onChange, variant } = props;
const parsedColor = useMemo(() => parseColor(color), [color]);
const satCoords = useMemo(() => getSaturationCoordinates(parsedColor), [
parsedColor
]);
const hueCoords = useMemo(() => getHueCoordinates(parsedColor), [
parsedColor
]);
const handleHexChange = useCallback(
(event) => {
var val = event.target.value;
if (val?.slice(0, 1) !== "#") {
val = "#" + val;
}
onChange(val);
},
[onChange]
);
const handleRgbChange = useCallback(
(component, value) => {
const { r, g, b } = parsedColor.rgb;
switch (component) {
case "r":
onChange(rgbToHex({ r: value ?? 0, g, b }));
return;
case "g":
onChange(rgbToHex({ r, g: value ?? 0, b }));
return;
case "b":
onChange(rgbToHex({ r, g, b: value ?? 0 }));
return;
default:
return;
}
},
[parsedColor, onChange]
);
const handleSaturationChange = useCallback(
(event) => {
const { width, height, left, top } = event.target.getBoundingClientRect();
const x = clamp(event.clientX - left, 0, width);
const y = clamp(event.clientY - top, 0, height);
const s = (x / width) * 100;
const v = 100 - (y / height) * 100;
const rgb = hsvToRgb({ h: parsedColor?.hsv.h, s, v });
onChange(rgbToHex(rgb));
},
[parsedColor, onChange]
);
const handleHueChange = useCallback(
(event) => {
const { width, left } = event.target.getBoundingClientRect();
const x = clamp(event.clientX - left, 0, width);
const h = Math.round((x / width) * 360);
const hsv = { h, s: parsedColor?.hsv.s, v: parsedColor?.hsv.v };
const rgb = hsvToRgb(hsv);
onChange(rgbToHex(rgb));
},
[parsedColor, onChange]
);
return (
<div className="cp-container">
{variant === ColorPickerVariant.Predefined ? (
<PredefinedSelector
colors={colors}
parsedColor={parsedColor}
onSelect={onChange}
/>
) : (
<FreeSelector
parsedColor={parsedColor}
satCoords={satCoords}
hueCoords={hueCoords}
onSaturationChange={handleSaturationChange}
onHueChange={handleHueChange}
/>
)}
<div className="cp-input-container">
<div className="cp-input-group">
<div
className="cp-color-preview"
style={{
background: color
}}
/>
<div>
<label className="cp-input-label" htmlFor="cp-input-hex">
Hex
</label>
<input
id="cp-input-hex"
className="cp-hex-input"
placeholder="Hex"
value={parsedColor?.hex}
onChange={handleHexChange}
/>
</div>
</div>
<div className="cp-input-group">
<div>
<label className="cp-input-label" htmlFor="cp-input-r">
R
</label>
<input
id="cp-input-r"
className="cp-rgb-input"
placeholder="R"
value={parsedColor.rgb.r}
onChange={(event) => handleRgbChange("r", event.target.value)}
inputMode="numeric"
pattern="[0-9]*"
/>
</div>
<div>
<label className="cp-input-label" htmlFor="cp-input-g">
G
</label>
<input
id="cp-input-g"
className="cp-rgb-input"
placeholder="G"
value={parsedColor.rgb.g}
onChange={(event) => handleRgbChange("g", event.target.value)}
inputMode="numeric"
pattern="[0-9]*"
/>
</div>
<div>
<label className="cp-input-label" htmlFor="cp-input-b">
B
</label>
<input
id="cp-input-b"
className="cp-rgb-input"
placeholder="B"
value={parsedColor.rgb.b}
onChange={(event) => handleRgbChange("b", event.target.value)}
inputMode="numeric"
pattern="[0-9]*"
/>
</div>
</div>
</div>
</div>
);
};
ColorPicker.defaultProps = {
color: DEFAULT_COLOR,
colors: DEFAULT_COLORS,
onChange: (color: string) => {},
variant: ColorPickerVariant.Predefined
};