Skip to content

Commit 203f386

Browse files
committed
feat: implement a custom color picker component
1 parent 0cdf240 commit 203f386

File tree

16 files changed

+259
-69
lines changed

16 files changed

+259
-69
lines changed

package-lock.json

+2-12
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/keybr-color/lib/color.ts

+8
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ const RE_HSLA =
4343

4444
/** Base abstract color class. */
4545
export abstract class Color {
46+
static tryParse(value: string): Color | null {
47+
try {
48+
return Color.parse(value);
49+
} catch {
50+
return null;
51+
}
52+
}
53+
4654
static parse(value: string): Color {
4755
let m: RegExpMatchArray | null;
4856
if ((m = value.match(RE_HEX_RGB))) {

packages/keybr-theme-designer/lib/design/input/ColorImport.module.less

-11
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,3 @@
1616
background-color: var(--Popup__background-color);
1717
box-shadow: var(--Popup--small__box-shadow);
1818
}
19-
20-
.adjust {
21-
display: flex;
22-
align-items: center;
23-
justify-content: center;
24-
}
25-
26-
.label {
27-
flex: 1;
28-
text-align: center;
29-
}

packages/keybr-theme-designer/lib/design/input/ColorInput.tsx

+7-40
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,11 @@ import {
44
type AnchorProps,
55
type Focusable,
66
getBoundingBox,
7-
Icon,
8-
IconButton,
97
Popover,
108
sizeClassName,
119
type SizeName,
1210
useOnClickOutside,
1311
} from "@keybr/widget";
14-
import { mdiMenuLeft, mdiMenuRight } from "@mdi/js";
1512
import { clsx } from "clsx";
1613
import {
1714
type ForwardedRef,
@@ -20,8 +17,8 @@ import {
2017
useRef,
2118
useState,
2219
} from "react";
23-
import { HexColorPicker } from "react-colorful";
2420
import { useCustomTheme } from "../context.ts";
21+
import { ColorPicker } from "./color/index.ts";
2522
import * as styles from "./ColorImport.module.less";
2623

2724
export const black = Color.parse("#000000");
@@ -37,7 +34,7 @@ export const makeAccessor = (prop: PropName): Accessor => {
3734
return {
3835
getColor: (theme) => theme.getColor(prop) ?? gray,
3936
setColor: (theme, color) => theme.set(prop, color),
40-
} as Accessor;
37+
};
4138
};
4239

4340
export function ColorInput({
@@ -70,42 +67,12 @@ export function ColorInput({
7067
offset={10}
7168
>
7269
<div ref={ref} className={styles.popup}>
73-
<HexColorPicker
74-
color={color.toRgb().formatHex()}
75-
onChange={(hex) => {
76-
setTheme(accessor.setColor(theme, Color.parse(hex)));
70+
<ColorPicker
71+
color={color}
72+
onChange={(color) => {
73+
setTheme(accessor.setColor(theme, color));
7774
}}
7875
/>
79-
<div className={styles.adjust}>
80-
<IconButton
81-
icon={<Icon shape={mdiMenuLeft} />}
82-
onClick={() => {
83-
setTheme(accessor.setColor(theme, color.lighten(-1 / 255)));
84-
}}
85-
/>
86-
<span className={styles.label}>lightness</span>
87-
<IconButton
88-
icon={<Icon shape={mdiMenuRight} />}
89-
onClick={() => {
90-
setTheme(accessor.setColor(theme, color.lighten(+1 / 255)));
91-
}}
92-
/>
93-
</div>
94-
<div className={styles.adjust}>
95-
<IconButton
96-
icon={<Icon shape={mdiMenuLeft} />}
97-
onClick={() => {
98-
setTheme(accessor.setColor(theme, color.saturate(-1 / 255)));
99-
}}
100-
/>
101-
<span className={styles.label}>saturation</span>
102-
<IconButton
103-
icon={<Icon shape={mdiMenuRight} />}
104-
onClick={() => {
105-
setTheme(accessor.setColor(theme, color.saturate(+1 / 255)));
106-
}}
107-
/>
108-
</div>
10976
</div>
11077
</Popover>
11178
);
@@ -144,7 +111,7 @@ const Button = forwardRef(function Button(
144111
ref={element}
145112
className={clsx(styles.root, sizeClassName(size))}
146113
style={{
147-
backgroundColor: color.toRgb().formatHex(),
114+
backgroundColor: String(color),
148115
}}
149116
onClick={onClick}
150117
/>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Color } from "@keybr/color";
2+
import { TextField } from "@keybr/widget";
3+
import { useEffect, useRef, useState } from "react";
4+
import type { ColorEditorProps } from "./types.ts";
5+
6+
export function ColorInput({ color, onChange }: ColorEditorProps) {
7+
const focus = useRef(false);
8+
const [value, setValue] = useState("");
9+
useEffect(() => {
10+
if (!focus.current) {
11+
setValue(String(color.toRgb()));
12+
}
13+
}, [color]);
14+
return (
15+
<TextField
16+
size="full"
17+
placeholder="hex, rgb, hsl, etc..."
18+
value={value}
19+
onChange={setValue}
20+
onFocus={() => {
21+
focus.current = true;
22+
}}
23+
onBlur={() => {
24+
focus.current = false;
25+
const color = Color.tryParse(value);
26+
if (color != null) {
27+
onChange(color);
28+
}
29+
}}
30+
/>
31+
);
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.root {
2+
background-color: #000;
3+
}
4+
5+
.saturation {
6+
position: relative;
7+
z-index: 2;
8+
box-sizing: content-box;
9+
inline-size: 256px;
10+
block-size: 256px;
11+
outline: none;
12+
}
13+
14+
.hue {
15+
position: relative;
16+
z-index: 1;
17+
box-sizing: content-box;
18+
inline-size: 256px;
19+
block-size: 2rem;
20+
outline: none;
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { HslColor } from "@keybr/color";
2+
import { Spacer } from "@keybr/widget";
3+
import { ColorInput } from "./ColorInput.tsx";
4+
import * as styles from "./ColorPicker.module.less";
5+
import { Slider } from "./Slider.tsx";
6+
import { Thumb } from "./Thumb.tsx";
7+
import { type ColorEditorProps } from "./types.ts";
8+
9+
export function ColorPicker({ color, onChange }: ColorEditorProps) {
10+
const { h, s, l } = color.toHsl();
11+
const saturationValue = { x: s, y: l };
12+
const hueValue = { x: h, y: 0.5 };
13+
const hueColor = new HslColor(h, 1, 0.5);
14+
return (
15+
<div className={styles.root}>
16+
<Slider
17+
className={styles.saturation}
18+
style={{
19+
backgroundColor: String(hueColor),
20+
backgroundImage: `linear-gradient(0deg,#000,transparent),linear-gradient(90deg,#fff,hsla(0,0%,100%,0))`,
21+
}}
22+
value={saturationValue}
23+
onChange={({ x, y }) => {
24+
onChange(new HslColor(h, x, y));
25+
}}
26+
>
27+
<Thumb color={color} value={saturationValue} />
28+
</Slider>
29+
<Spacer size={1} />
30+
<Slider
31+
className={styles.hue}
32+
style={{
33+
backgroundImage: `linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)`,
34+
}}
35+
value={hueValue}
36+
onChange={({ x }) => {
37+
onChange(new HslColor(x, s, l));
38+
}}
39+
>
40+
<Thumb color={hueColor} value={hueValue} />
41+
</Slider>
42+
<ColorInput color={color} onChange={onChange} />
43+
</div>
44+
);
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { clamp } from "@keybr/lang";
2+
import {
3+
getBoundingBox,
4+
useHotkeysHandler,
5+
useWindowEvent,
6+
} from "@keybr/widget";
7+
import { type CSSProperties, type ReactElement, useRef } from "react";
8+
import { type SliderValue } from "./types.ts";
9+
10+
export function Slider({
11+
className,
12+
style,
13+
children,
14+
value: { x, y },
15+
onChange,
16+
}: {
17+
readonly className: string;
18+
readonly style?: CSSProperties;
19+
readonly children: ReactElement;
20+
readonly value: SliderValue;
21+
readonly onChange: (value: SliderValue) => void;
22+
}) {
23+
const ref = useRef<HTMLDivElement>(null);
24+
const tracking = useRef(false);
25+
useWindowEvent("mousemove", (event) => {
26+
if (tracking.current) {
27+
event.preventDefault();
28+
onChange(getValue(ref.current!, event));
29+
}
30+
});
31+
useWindowEvent("mouseup", () => {
32+
tracking.current = false;
33+
});
34+
const moveLeft = () => {
35+
if (x > 0) {
36+
onChange({ x: Math.max(0, x - 0.01), y });
37+
}
38+
};
39+
const moveRight = () => {
40+
if (x < 1) {
41+
onChange({ x: Math.min(1, x + 0.01), y });
42+
}
43+
};
44+
const moveUp = () => {
45+
if (y < 1) {
46+
onChange({ x, y: Math.min(1, y + 0.01) });
47+
}
48+
};
49+
const moveDown = () => {
50+
if (y > 0) {
51+
onChange({ x, y: Math.max(0, y - 0.01) });
52+
}
53+
};
54+
return (
55+
<div
56+
ref={ref}
57+
className={className}
58+
style={style}
59+
tabIndex={0}
60+
onMouseDown={(event) => {
61+
ref.current!.focus();
62+
tracking.current = true;
63+
event.preventDefault();
64+
onChange(getValue(ref.current!, event));
65+
}}
66+
onKeyDown={useHotkeysHandler(
67+
["ArrowLeft", moveLeft],
68+
["ArrowRight", moveRight],
69+
["ArrowUp", moveUp],
70+
["ArrowDown", moveDown],
71+
)}
72+
>
73+
{children}
74+
</div>
75+
);
76+
}
77+
78+
function getValue(
79+
element: HTMLElement,
80+
{ clientX, clientY }: { readonly clientX: number; readonly clientY: number },
81+
): SliderValue {
82+
const {
83+
left, //
84+
top,
85+
right,
86+
bottom,
87+
width,
88+
height,
89+
} = getBoundingBox(element, "fixed");
90+
return {
91+
x: (clamp(clientX, left, right) - left) / width,
92+
y: 1 - (clamp(clientY, top, bottom) - top) / height,
93+
};
94+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.root {
2+
position: absolute;
3+
box-sizing: content-box;
4+
inline-size: 2rem;
5+
block-size: 2rem;
6+
border: 3px solid #fff;
7+
border-radius: 50%;
8+
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
9+
transform: translate(-50%, -50%);
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { type Color } from "@keybr/color";
2+
import * as styles from "./Thumb.module.less";
3+
import { type SliderValue } from "./types.ts";
4+
5+
export function Thumb({
6+
color,
7+
value: { x, y },
8+
}: {
9+
readonly color: Color;
10+
readonly value: SliderValue;
11+
}) {
12+
return (
13+
<div
14+
className={styles.root}
15+
style={{
16+
left: `${x * 100}%`,
17+
top: `${(1 - y) * 100}%`,
18+
backgroundColor: String(color),
19+
}}
20+
/>
21+
);
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./ColorPicker.tsx";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { type Color } from "@keybr/color";
2+
3+
export type ColorEditorProps = {
4+
readonly color: Color;
5+
readonly onChange: (color: Color) => void;
6+
};
7+
8+
export type SliderValue = {
9+
x: number;
10+
y: number;
11+
};

0 commit comments

Comments
 (0)