SAIやクリップスタジオペイントなどの和製ペイントソフトにありがちなカラーピッカーが直感的で好きなんですが、いかがでしょうか。
Win/Chromeで動作確認しています。
技術要素的にはIE以外では動きそう。
React初心者です。
adialColorPicker.tsx
import React, { useEffect, useRef, useState } from 'react';
import './RadialColorPicker.scss';
function rgbToHsv(rgb) {
let { r, g, b } = rgb;
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
let h,
s,
v = max;
const d = max - min;
s = max == 0 ? 0 : d / max;
if (max == min) {
h = 0; // achromatic
} else {
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return { h, s, v };
}
function hsvToRgb(hsv) {
let { h, s, v } = hsv;
let r, g, b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
(r = v), (g = t), (b = p);
break;
case 1:
(r = q), (g = v), (b = p);
break;
case 2:
(r = p), (g = v), (b = t);
break;
case 3:
(r = p), (g = q), (b = v);
break;
case 4:
(r = t), (g = p), (b = v);
break;
case 5:
(r = v), (g = p), (b = q);
break;
}
r *= 255;
g *= 255;
b *= 255;
return { r, g, b };
}
function sanitize(val) {
return Math.min(1, Math.max(0, val));
}
function sanitizeHue(val) {
return val - Math.floor(val);
}
const colorPattern = /^#[0-9a-fA-F]{6}$/;
const RadialColorPicker: React.FC<{ value: string; onChange: (hash: string) => void }> = ({
value,
onChange
}) => {
const [hsv, setHsv] = useState({ h: 0, s: 0, v: 100 });
const [hash, setHash] = useState(`#ffffff`);
useEffect(() => {
if (value !== hash && colorPattern.test(value)) {
const hex = value?.replace('#', '');
const hsv = rgbToHsv({
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16)
});
setHsv(hsv);
}
}, [value]);
const [hueMouseDown, setHueMouseDown] = useState(false);
const [satValMouseDown, setSatValMouseDown] = useState(false);
const circleRef = useRef<HTMLDivElement>();
const rectRef = useRef<HTMLDivElement>();
useEffect(() => {
const handleDocumentMouseUp = () => {
setHueMouseDown(false);
setSatValMouseDown(false);
};
const handleDocumentMouseMove = (event) => {
if (event.changedTouches) {
if (hueMouseDown) onClickHue(event.changedTouches[0]);
else if (satValMouseDown) onClickValSat(event.changedTouches[0]);
} else {
if (hueMouseDown) onClickHue(event);
else if (satValMouseDown) onClickValSat(event);
}
};
document.addEventListener('mouseup', handleDocumentMouseUp);
document.addEventListener('touchend', handleDocumentMouseUp);
document.addEventListener('touchcancel', handleDocumentMouseUp);
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('touchmove', handleDocumentMouseMove);
return () => {
document.removeEventListener('mouseup', handleDocumentMouseUp);
document.removeEventListener('touchend', handleDocumentMouseUp);
document.removeEventListener('touchcancel', handleDocumentMouseUp);
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('touchmove', handleDocumentMouseMove);
};
}, [hueMouseDown, satValMouseDown]);
useEffect(() => {
const { r, g, b } = hsvToRgb(hsv);
setHash(
'#' +
Math.round(r).toString(16).padStart(2, '0') +
Math.round(g).toString(16).padStart(2, '0') +
Math.round(b).toString(16).padStart(2, '0')
);
}, [hsv]);
useEffect(() => {
onChange(hash);
}, [hash]);
const onClickHue = (ev: { clientX: number; clientY: number }) => {
const rect = circleRef.current.getBoundingClientRect();
setHsv({
h: sanitizeHue(
Math.atan2(
-(ev.clientX - rect.left - rect.width / 2),
ev.clientY - rect.top - rect.height / 2
) /
(Math.PI * 2) +
0.625
),
s: hsv.s,
v: hsv.v
});
};
const onClickValSat = (ev: { clientX: number; clientY: number }) => {
const rect = rectRef.current.getBoundingClientRect();
setHsv({
h: hsv.h,
s: sanitize((ev.clientX - rect.left) / rect.width),
v: sanitize(1 - (ev.clientY - rect.top) / rect.height)
});
};
const cursorSize = 16;
const size = 200;
return (
<div
className="wrapper"
style={
{
'--size': size + 'px',
'--cursor-size': cursorSize + 'px'
} as React.CSSProperties
}>
<div
className="hue-circle"
onClick={onClickHue}
onMouseDown={() => setHueMouseDown(true)}
onTouchStart={() => setHueMouseDown(true)}
ref={circleRef}>
<div
className="hue-cursor"
style={{
top: ((size - cursorSize) / 2) * (1 - Math.sin((hsv.h + 0.125) * Math.PI * 2)) + 'px',
left: ((size - cursorSize) / 2) * (1 - Math.cos((hsv.h + 0.125) * Math.PI * 2)) + 'px'
}}
/>
</div>
<div
className="val-sat-rect"
onClick={onClickValSat}
onMouseDown={() => setSatValMouseDown(true)}
onTouchStart={() => setSatValMouseDown(true)}
ref={rectRef}
style={
{
'--hue': Math.round(hsv.h * 360)
} as React.CSSProperties
}>
<div
className="val-sat-cursor"
style={{
top: (size / Math.sqrt(2) - cursorSize * 2) * (1 - hsv.v) - cursorSize / 2 + 'px',
left: (size / Math.sqrt(2) - cursorSize * 2) * hsv.s - cursorSize / 2 + 'px'
}}
/>
</div>
</div>
);
};
export default RadialColorPicker;
RadialColorPicker.scss
.wrapper {
$sqrt2: 1.414;
position: relative;
width: var(--size);
background: #eee;
touch-action: none;
.hue-cursor,
.val-sat-cursor {
position: absolute;
border: 3px solid white;
width: var(--cursor-size);
height: var(--cursor-size);
border-radius: 100%;
z-index: 2;
}
.hue-circle {
position: relative;
width: 100%;
border-radius: 100%;
mask-image: radial-gradient(
transparent calc(100% / $sqrt2 - var(--cursor-size)),
white calc(100% / $sqrt2 - var(--cursor-size))
);
&::before {
content: '';
display: block;
width: 100%;
padding-top: 100%;
overflow: hidden;
background: conic-gradient(
hsl(45, 100%, 50%),
hsl(90, 100%, 50%),
hsl(135, 100%, 50%),
hsl(180, 100%, 50%),
hsl(225, 100%, 50%),
hsl(270, 100%, 50%),
hsl(315, 100%, 50%),
hsl(360, 100%, 50%),
hsl(45, 100%, 50%)
);
}
}
.val-sat-rect {
position: absolute;
width: calc(100% / $sqrt2 - var(--cursor-size) * 2);
height: calc(100% / $sqrt2 - var(--cursor-size) * 2);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-blend-mode: multiply;
background: linear-gradient(to right, hsl(var(--hue), 0%, 100%), hsl(var(--hue), 100%, 50%)),
linear-gradient(to bottom, hsl(var(--hue), 0%, 100%), hsl(var(--hue), 0%, 0%)), white;
z-index: 1;
}
}