@terrazzo/react-color-picker
Version:
React color picker that supports Color Module 4, wide color gamut (WCG), and Display-P3 using WebGL for monitor-accurate colors. Powered by Culori.
1,180 lines (1,039 loc) • 40.4 kB
JavaScript
import { jsx, jsxs } from 'react/jsx-runtime';
import { Slider, Select, SelectItem } from '@terrazzo/tiles';
import { COLORSPACES, formatCss, withAlpha, parse } from '@terrazzo/use-color';
import { toGamut, useMode, modeRgb, modeLrgb, modeOklab } from 'culori';
import { useRef, useState, useEffect, useMemo } from 'react';
import { ColorFilterOutline } from '@terrazzo/icons';
import clsx from 'clsx';
/** Calculate min, max, displayMin, and displayMax for a given color/colorspace/gamut */
function calculateBounds(color, channel) {
let min = 0;
let max = 1;
switch (color.mode) {
case 'hsl':
case 'hwb':
case 'okhsl':
case 'okhsv': {
switch (channel) {
case 'h': {
max = 360;
}
}
break;
}
case 'lab': {
switch (channel) {
case 'l': {
max = 100;
break;
}
case 'a':
case 'b': {
min = -125;
max = 125;
break;
}
}
break;
}
case 'lch': {
switch (channel) {
case 'l': {
max = 100;
break;
}
case 'c': {
max = 150;
break;
}
case 'h': {
max = 360;
break;
}
}
break;
}
case 'oklab': {
if (channel === 'a' || channel === 'b') {
min = -0.4;
max = 0.4;
}
break;
}
case 'oklch': {
switch (channel) {
case 'h': {
max = 360;
break;
}
case 'c': {
max = 0.4;
break;
}
}
break;
}
}
const result = { min, max };
return result;
}
/** Handle color gamut clamping */
function updateColor(color, gamut = 'rgb') {
switch (gamut) {
// encompasses P3
case 'rec2020': {
// no clamping necessary
if (color.mode === 'rgb' || color.mode === 'p3' || color.mode === 'hsl' || color.mode === 'hwb') {
return COLORSPACES.rec2020.converter(color); // if this is in a non-Rec2020-compatible colorspace, convert it
}
break;
}
case 'p3': {
if (color.mode === 'rec2020' || color.mode === 'rgb' || color.mode === 'hsl' || color.mode === 'hwb') {
const clamped = toGamut('p3', 'oklch')(color); // clamp color to P3 gamut
return COLORSPACES.p3.converter(clamped); // if this is in a non-P3-compatible colorspace, convert it
}
break;
}
default: {
if (color.mode === 'a98' || color.mode === 'rec2020' || color.mode === 'p3' || color.mode === 'prophoto') {
const clamped = toGamut('rgb', 'oklch')(color); // clamp to sRGB gamut
return COLORSPACES.srgb.converter(clamped); // if this is in a non-sRGB-compatible colorspace, convert it
}
break;
}
}
return color;
}
/** Order color components in proper order */
function channelOrder(color) {
switch (color.mode) {
case 'rgb':
case 'rec2020':
case 'lrgb':
case 'a98':
case 'prophoto': {
return ['r', 'g', 'b', 'alpha'];
}
case 'hsl':
case 'okhsl': {
return ['h', 's', 'l', 'alpha'];
}
case 'okhsv': {
return ['h', 's', 'v', 'alpha'];
}
case 'hwb': {
return ['h', 'w', 'b', 'alpha'];
}
case 'lab':
case 'oklab': {
return ['l', 'a', 'b', 'alpha'];
}
case 'lch':
case 'oklch': {
return ['l', 'c', 'h', 'alpha'];
}
case 'xyz50':
case 'xyz65': {
return ['x', 'y', 'z', 'alpha'];
}
default: {
return Object.keys(color).filter((k) => k !== 'mode');
}
}
}
// Copyright(c) 2021 Björn Ottosson
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
/**
* Björn Ottosson’s open source implementation of Oklab.
* @warning Requires loading `./rgb.js` first to use!
*/
const OKLAB = `float cbrt(float x) {
return sign(x) * pow(abs(x), 1.0 / 3.0);
}
vec4 lab_to_lch(vec4 lab) {
float chroma = sqrt(lab.y * lab.y + lab.z * lab.z);
float hue = abs(lab.y) > 0.00001 && abs(lab.z) > 0.00001 ? degrees(atan(lab.z, lab.y)) : 0.0;
if (hue < 0.0) hue += 360.0;
return vec4(lab.x, chroma, hue, lab.w);
}
vec4 lch_to_lab(vec4 lch) {
// return black if lightness is sufficiently dark
if (lch.x < 0.00001) {
return vec4(0.0, 0.0, 0.0, lch.w);
}
return vec4(
lch.x,
lch.y * cos(radians(lch.z)),
lch.y * sin(radians(lch.z)),
lch.w
);
}
vec4 oklab_to_linear_rgb(vec4 oklab) {
float l = pow(oklab.x + 0.3963377774 * oklab.y + 0.2158037573 * oklab.z, 3.0);
float m = pow(oklab.x - 0.1055613458 * oklab.y - 0.0638541728 * oklab.z, 3.0);
float s = pow(oklab.x - 0.0894841775 * oklab.y - 1.2914855480 * oklab.z, 3.0);
return vec4(
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
oklab.w
);
}
vec4 linear_rgb_to_oklab(vec4 srgb) {
float l = cbrt(0.4122214708 * srgb.x + 0.5363325363 * srgb.y + 0.0514459929 * srgb.z);
float m = cbrt(0.2119034982 * srgb.x + 0.6806995451 * srgb.y + 0.1073969566 * srgb.z);
float s = cbrt(0.0883024619 * srgb.x + 0.2817188376 * srgb.y + 0.6299787005 * srgb.z);
return vec4(
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
srgb.w
);
}
// Finds the maximum saturation possible for a given hue that fits in sRGB
// Saturation here is defined as S = C/L
// a and b must be normalized so a^2 + b^2 == 1
float compute_max_saturation(float a, float b) {
// Max saturation will be when one of r, g or b goes below zero.
// Select different coefficients depending on which component goes below zero first
float k0, k1, k2, k3, k4, wl, wm, ws;
if (-1.88170328 * a - 0.80936493 * b > 1.0) {
// Red component
k0 = +1.19086277; k1 = +1.76576728; k2 = +0.59662641; k3 = +0.75515197; k4 = +0.56771245;
wl = +4.0767416621; wm = -3.3077115913; ws = +0.2309699292;
}
else if (1.81444104 * a - 1.19445276 * b > 1.0) {
// Green component
k0 = +0.73956515; k1 = -0.45954404; k2 = +0.08285427; k3 = +0.12541070; k4 = +0.14503204;
wl = -1.2684380046; wm = +2.6097574011; ws = -0.3413193965;
}
else {
// Blue component
k0 = +1.35733652; k1 = -0.00915799; k2 = -1.15130210; k3 = -0.50559606; k4 = +0.00692167;
wl = -0.0041960863; wm = -0.7034186147; ws = +1.7076147010;
}
// Approximate max saturation using a polynomial:
float S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
// Do one step Halley's method to get closer
// this gives an error less than 10e6, except for some blue hues where the dS/dh is close to infinite
// this should be sufficient for most applications, otherwise do two/three steps
float k_l = +0.3963377774 * a + 0.2158037573 * b;
float k_m = -0.1055613458 * a - 0.0638541728 * b;
float k_s = -0.0894841775 * a - 1.2914855480 * b;
{
float l_ = 1.0 + S * k_l;
float m_ = 1.0 + S * k_m;
float s_ = 1.0 + S * k_s;
float l = l_ * l_ * l_;
float m = m_ * m_ * m_;
float s = s_ * s_ * s_;
float l_dS = 3.0 * k_l * l_ * l_;
float m_dS = 3.0 * k_m * m_ * m_;
float s_dS = 3.0 * k_s * s_ * s_;
float l_dS2 = 6.0 * k_l * k_l * l_;
float m_dS2 = 6.0 * k_m * k_m * m_;
float s_dS2 = 6.0 * k_s * k_s * s_;
float f = wl * l + wm * m + ws * s;
float f1 = wl * l_dS + wm * m_dS + ws * s_dS;
float f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2;
S = S - f * f1 / (f1 * f1 - 0.5 * f * f2);
}
return S;
}
// finds L_cusp and C_cusp for a given hue
// a and b must be normalized so a^2 + b^2 == 1
vec2 find_cusp(float a, float b) {
// First, find the maximum saturation (saturation S = C/L)
float S_cusp = compute_max_saturation(a, b);
// Convert to linear sRGB to find the first point where at least one of r,g or b >= 1:
vec4 rgb_at_max = oklab_to_linear_rgb(vec4(1.0, S_cusp * a, S_cusp * b, 1.0));
float L_cusp = cbrt(1.0 / max(max(rgb_at_max.x, rgb_at_max.y), rgb_at_max.z));
float C_cusp = L_cusp * S_cusp;
return vec2(L_cusp, C_cusp);
}
// Finds intersection of the line defined by
// L = L0 * (1 - t) + t * L1;
// C = t * C1;
// a and b must be normalized so a^2 + b^2 == 1
float find_gamut_intersection(float a, float b, float L1, float C1, float L0) {
vec2 cusp = find_cusp(a, b);
float cusp_l = cusp.x;
float cusp_c = cusp.y;
// Find the intersection for upper and lower half seprately
float t;
// Lower half
if (((L1 - L0) * cusp_c - (cusp_l - L0) * C1) <= 0.0) {
t = cusp_c * L0 / (C1 * cusp_l + cusp_c * (L0 - L1));
}
// Upper half
else {
// First intersect with triangle
t = cusp_c * (L0 - 1.0) / (C1 * (cusp_l - 1.0) + cusp_c * (L0 - L1));
// Then one step Halley's method
{
float dL = L1 - L0;
float dC = C1;
float k_l = +0.3963377774 * a + 0.2158037573 * b;
float k_m = -0.1055613458 * a - 0.0638541728 * b;
float k_s = -0.0894841775 * a - 1.2914855480 * b;
float l_dt = dL + dC * k_l;
float m_dt = dL + dC * k_m;
float s_dt = dL + dC * k_s;
// If higher accuracy is required, 2 or 3 iterations of the following block can be used:
{
float L = L0 * (1.0 - t) + t * L1;
float C = t * C1;
float l_ = L + C * k_l;
float m_ = L + C * k_m;
float s_ = L + C * k_s;
float l = l_ * l_ * l_;
float m = m_ * m_ * m_;
float s = s_ * s_ * s_;
float ldt = 3.0 * l_dt * l_ * l_;
float mdt = 3.0 * m_dt * m_ * m_;
float sdt = 3.0 * s_dt * s_ * s_;
float ldt2 = 6.0 * l_dt * l_dt * l_;
float mdt2 = 6.0 * m_dt * m_dt * m_;
float sdt2 = 6.0 * s_dt * s_dt * s_;
float r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1.0;
float r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt;
float r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2;
float u_r = r1 / (r1 * r1 - 0.5 * r * r2);
float t_r = -r * u_r;
float g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1.0;
float g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt;
float g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2;
float u_g = g1 / (g1 * g1 - 0.5 * g * g2);
float t_g = -g * u_g;
float b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s - 1.0;
float b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt;
float b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2;
float u_b = b1 / (b1 * b1 - 0.5 * b * b2);
float t_b = -b * u_b;
t_r = u_r >= 0.0 ? t_r : 10.0e5;
t_g = u_g >= 0.0 ? t_g : 10.0e5;
t_b = u_b >= 0.0 ? t_b : 10.0e5;
t += min(t_r, min(t_g, t_b));
}
}
}
return t;
}
// clamp_mode
// https://bottosson.github.io/posts/gamutclipping/
// 0 = no clamp (default)
// 1 = keep lightness, clamp chroma
// 2 = projection toward point, hue independent
// 3 = projection toward point, hue dependent
// 4 = adaptive Lightness, hue independent
// 5 = adaptive Lightness, hue dependent
vec4 oklab_to_srgb(vec4 oklab, int clamp_mode) {
vec4 linear_rgb = oklab_to_linear_rgb(oklab);
vec4 srgb = linear_rgb_to_srgb(linear_rgb);
// for anything sufficiently dark, return 0,0,0 (rather than negative RGB values)
if (srgb.x < 0.0001 && srgb.y < 0.0001 && srgb.z < 0.0001) {
return vec4(0.0, 0.0, 0.0, oklab.w);
}
// if inside sRGB gamut, return
if (
(linear_rgb.x > 0.0 && linear_rgb.x < 1.0 &&
linear_rgb.y > 0.0 && linear_rgb.y < 1.0 &&
linear_rgb.z > 0.0 && linear_rgb.z < 1.0) || clamp_mode == 0) {
return srgb;
}
float eps = 0.00001;
float alpha = 0.05; // TODO: allow config?
float c = max(eps, sqrt(pow(oklab.y, 2.0) + pow(oklab.z, 2.0)));
float L0 = oklab.x;
float a = oklab.y / c;
float b = oklab.z / c;
// 1. keep lightness, clamp chroma
if (clamp_mode == 1) {
// The cusp is computed here and in find_gamut_intersection, an optimized solution would only compute it once.
L0 = clamp(oklab.x, 0.0, 1.0);
}
// 2. projection toward point, hue independent
else if (clamp_mode == 2) {
L0 = 0.5;
}
// 3. projection toward point, hue dependent
else if (clamp_mode == 3) {
L0 = find_cusp(a, b).x;
}
// 4. adaptive Lightness, hue independent
else if (clamp_mode == 4) {
float Ld = oklab.x - 0.5;
float e1 = 0.5 + abs(Ld) + alpha * c;
L0 = 0.5 * (1.0 + sign(Ld) * (e1 - sqrt(pow(e1, 2.0) - 2.0 * abs(Ld))));
}
// 5. adaptive Lightness, hue dependent
else if (clamp_mode == 5) {
float cusp_l = find_cusp(a, b).x;
float Ld = oklab.x - cusp_l;
float k = 2.0 * (Ld >= 0.0 ? 1.0 - cusp_l : cusp_l);
float e1 = 0.5 * k + abs(Ld) + alpha * c / k;
L0 = cusp_l + 0.5 * (sign(Ld) * (e1 - sqrt(pow(e1, 2.0) - 2.0 * k * abs(Ld))));
}
float t = find_gamut_intersection(a, b, oklab.x - 0.1, c, L0);
float l_clipped = L0 * (1.0 - t) + t * oklab.x;
float c_clipped = t * c;
vec4 clamped_oklab = vec4(l_clipped, c_clipped * a, c_clipped * b, oklab.w);
return linear_rgb_to_srgb(oklab_to_linear_rgb(clamped_oklab));
}
vec4 oklch_to_srgb(vec4 oklch, int clamp_mode) {
return oklab_to_srgb(lch_to_lab(oklch), clamp_mode);
}
vec4 srgb_to_oklch(vec4 srgb) {
vec4 linear_rgb = srgb_to_linear_rgb(srgb);
vec4 oklab = linear_rgb_to_oklab(linear_rgb);
return lab_to_lch(oklab);
}
`;
const OKHSL = `// toe function for L_r
float toe(float x) {
float k_1 = 0.206;
float k_2 = 0.03;
float k_3 = (1.0 + k_1) / (1.0 + k_2);
return 0.5 * (k_3 * x - k_1 + sqrtf((k_3 * x - k_1) * (k_3 * x - k_1) + 4 * k_2 * k_3 * x));
}
// inverse toe function for L_r
float toe_inv(float x) {
float k_1 = 0.206;
float k_2 = 0.03;
float k_3 = (1.0 + k_1) / (1.0 + k_2);
return (x * x + k_1 * x) / (k_3 * (x + k_2));
}
to_ST(vec2 cusp) {
float L = cusp.x;
float C = cusp.y;
return vec2(C / L, C / (1.0 - L));
}
okhsv_to_srgb(vec4 hsv) {
float h = hsv.x;
float s = hsv.y;
float v = hsv.z;
float alpha = hsv.w;
float a_ = cosf(2.0 * pi * h);
float b_ = sinf(2.0 * pi * h);
LC cusp = find_cusp(a_, b_);
ST ST_max = to_ST(cusp);
float S_max = ST_max.x;
float T_max = ST_max.y;
float S_0 = 0.5;
float k = 1 - S_0 / S_max;
// first we compute L and V as if the gamut is a perfect triangle:
// L, C when v==1:
float L_v = 1 - s * S_0 / (S_0 + T_max - T_max * k * s);
float C_v = s * T_max * S_0 / (S_0 + T_max - T_max * k * s);
float L = v * L_v;
float C = v * C_v;
// then we compensate for both toe and the curved top part of the triangle:
float L_vt = toe_inv(L_v);
float C_vt = C_v * L_vt / L_v;
float L_new = toe_inv(L);
C = C * L_new / L;
L = L_new;
vec4 rgb_scale = oklab_to_linear_srgb(vec4(L_vt, a_ * C_vt, b_ * C_vt, alpha));
float scale_L = cbrtf(1.0 / fmax(fmax(rgb_scale.x, rgb_scale.y), fmax(rgb_scale.z, 0.0)));
L = L * scale_L;
C = C * scale_L;
vec4 rgb = oklab_to_linear_srgb(vec4(L, C * a_, C * b_, alpha));
return vec4(
srgb_transfer_function(rgb.x),
srgb_transfer_function(rgb.y),
srgb_transfer_function(rgb.z),
rgb.w
);
}
vec4 srgb_to_okhsv(vec4 rgb) {
vec4 lab = linear_srgb_to_oklab(
srgb_transfer_function_inv(rgb.x),
srgb_transfer_function_inv(rgb.y),
srgb_transfer_function_inv(rgb.z),
rgb.w
);
float C = sqrtf(lab.y * lab.y + lab.z * lab.z);
float a_ = lab.y / C;
float b_ = lab.z / C;
float L = lab.x;
float h = 0.5 + 0.5 * atan2f(-lab.z, -lab.y) / pi;
vec2 cusp = find_cusp(a_, b_);
vec2 ST_max = to_ST(cusp);
float S_max = ST_max.x;
float T_max = ST_max.y;
float S_0 = 0.5;
float k = 1 - S_0 / S_max;
// first we find L_v, C_v, L_vt and C_vt
float t = T_max / (C + L * T_max);
float L_v = t * L;
float C_v = t * C;
float L_vt = toe_inv(L_v);
float C_vt = C_v * L_vt / L_v;
// we can then use these to invert the step that compensates for the toe and the curved top part of the triangle:
vec4 rgb_scale = oklab_to_linear_srgb(vec4(L_vt, a_ * C_vt, b_ * C_vt, rgb.w));
float scale_L = cbrtf(1.0 / fmax(fmax(rgb_scale.x, rgb_scale.y), fmax(rgb_scale.z, 0.0)));
L = L / scale_L;
C = C / scale_L;
C = C * toe(L) / L;
L = toe(L);
// we can now compute v and s:
float v = L / L_v;
float s = (S_0 + T_max) * C_v / ((T_max * S_0) + T_max * k * C_v);
return vec4(h, s, v, rgb.w);
}
vec2 get_ST_mid(float a_, float b_) {
float S = 0.11516993f + 1.0 / (
+7.44778970f + 4.15901240f * b_
+ a_ * (-2.19557347f + 1.75198401f * b_
+ a_ * (-2.13704948f - 10.02301043f * b_
+ a_ * (-4.24894561f + 5.38770819f * b_ + 4.69891013f * a_
)))
);
float T = 0.11239642f + 1.0 / (
+1.61320320f - 0.68124379f * b_
+ a_ * (+0.40370612f + 0.90148123f * b_
+ a_ * (-0.27087943f + 0.61223990f * b_
+ a_ * (+0.00299215f - 0.45399568f * b_ - 0.14661872f * a_
)))
);
return vec2(S, T);
}
vec3 get_Cs(float L, float a_, float b_) {
vec2 cusp = find_cusp(a_, b_);
float C_max = find_gamut_intersection(a_, b_, L, 1, L, cusp);
vec2 ST_max = to_ST(cusp);
// Scale factor to compensate for the curved part of gamut shape:
float k = C_max / fmin((L * ST_max.x), (1 - L) * ST_max.y);
float C_mid;
{
vec2 ST_mid = get_ST_mid(a_, b_);
// Use a soft minimum function, instead of a sharp triangle shape to get a smooth value for chroma.
float C_a = L * ST_mid.x;
float C_b = (1.0 - L) * ST_mid.y;
C_mid = 0.9f * k * sqrtf(sqrtf(1.0 / (1.0 / (C_a * C_a * C_a * C_a) + 1.0 / (C_b * C_b * C_b * C_b))));
}
float C_0;
{
// for C_0, the shape is independent of hue, so ST are constant. Values picked to roughly be the average values of ST.
float C_a = L * 0.4;
float C_b = (1.0 - L) * 0.8;
// Use a soft minimum function, instead of a sharp triangle shape to get a smooth value for chroma.
C_0 = sqrtf(1.0 / (1.0 / (C_a * C_a) + 1.0 / (C_b * C_b)));
}
return vec3(C_0, C_mid, C_max);
}
vec4 okhsl_to_srgb(vec4 hsl) {
float h = hsl.x;
float s = hsl.y;
float l = hsl.z;
if (l == 1.0) {
return vec4(1.0, 1.0, 1.0, hsl.w);
}
else if (l == 0.0) {
return vec4(0.0, 0.0, 0.0, hsl.w);
}
float a_ = cosf(2.0 * pi * h);
float b_ = sinf(2.0 * pi * h);
float L = toe_inv(l);
vec3 cs = get_Cs(L, a_, b_);
float C_0 = cs.x;
float C_mid = cs.y;
float C_max = cs.z;
// Interpolate the three values for C so that:
// At s=0: dC/ds = C_0, C=0
// At s=0.8: C=C_mid
// At s=1.0: C=C_max
float mid = 0.8;
float mid_inv = 1.25;
float C, t, k_0, k_1, k_2;
if (s < mid) {
t = mid_inv * s;
k_1 = mid * C_0;
k_2 = (1.0 - k_1 / C_mid);
C = t * k_1 / (1.0 - k_2 * t);
} else {
t = (s - mid)/ (1 - mid);
k_0 = C_mid;
k_1 = (1.0 - mid) * C_mid * C_mid * mid_inv * mid_inv / C_0;
k_2 = (1.0 - (k_1) / (C_max - C_mid));
C = k_0 + t * k_1 / (1.0 - k_2 * t);
}
vec4 rgb = oklab_to_linear_srgb(vec4(L, C * a_, C * b_, hsl.w));
return {
srgb_transfer_function(rgb.x),
srgb_transfer_function(rgb.y),
srgb_transfer_function(rgb.z),
hsl.w
};
}
vec4 srgb_to_okhsl(vec4 rgb) {
vec4 lab = linear_srgb_to_oklab(
srgb_transfer_function_inv(rgb.x),
srgb_transfer_function_inv(rgb.y),
srgb_transfer_function_inv(rgb.z),
rgb.w
});
float C = sqrtf(lab.y * lab.y + lab.z * lab.z);
float a_ = lab.y / C;
float b_ = lab.y / C;
float L = lab.x;
float h = 0.5 + 0.5 * atan2f(-lab.z, -lab.y) / pi;
vec2 cs = get_Cs(L, a_, b_);
float C_0 = cs.x;
float C_mid = cs.y;
float C_max = cs.z;
// Inverse of the interpolation in okhsl_to_srgb:
float mid = 0.8;
float mid_inv = 1.25;
float s;
if (C < C_mid) {
float k_1 = mid * C_0;
float k_2 = (1.0 - k_1 / C_mid);
float t = C / (k_1 + k_2 * C);
s = t * mid;
} else {
float k_0 = C_mid;
float k_1 = (1.0 - mid) * C_mid * C_mid * mid_inv * mid_inv / C_0;
float k_2 = (1.0 - (k_1) / (C_max - C_mid));
float t = (C - k_0) / (k_1 + k_2 * (C - k_0));
s = mid + (1.0 - mid) * t;
}
float l = toe(L);
return vec4(h, s, l, rgb.w);
}`;
/** Generic implementation of the sRGB transfer function */
const LINEAR_RGB = `float srgb_transfer_fn(float a) {
float v = abs(a);
return v <= 0.0031308 ? 12.92 * v : 1.055 * pow(v, (1.0 / 2.4)) - 0.055;
}
float srgb_inverse_transfer_fn(float a) {
float v = abs(a);
return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4);
}
vec4 srgb_to_linear_rgb(vec4 srgb) {
return vec4(srgb_inverse_transfer_fn(srgb.x), srgb_inverse_transfer_fn(srgb.y), srgb_inverse_transfer_fn(srgb.z), srgb.w);
}
vec4 linear_rgb_to_srgb(vec4 linear_rgb) {
return vec4(srgb_transfer_fn(linear_rgb.x), srgb_transfer_fn(linear_rgb.y), srgb_transfer_fn(linear_rgb.z), linear_rgb.w);
}
// Blend 2 vec4 colors together
vec4 avg_vec4(vec4 a, vec4 b, float w) {
float _w = 1.0 - w;
return (a * _w) + (b * w);
}
vec4 blend_srgb(vec4 a, vec4 b, float w) {
vec4 lrgb_a = srgb_to_linear_rgb(a);
vec4 lrgb_b = srgb_to_linear_rgb(b);
vec4 blended = linear_rgb_to_srgb(avg_vec4(a, b, w));
blended.w = 1.0;
return blended;
}
`;
/** create a WebGL2 rendering context and throw errors if needed */
function createRenderingContext(canvas) {
// init GL
const gl = canvas.getContext('webgl2');
if (!gl) {
throw new Error('Could not get WebGL context');
}
const canvasRect = canvas.getBoundingClientRect();
canvas.width = canvasRect.width;
canvas.height = canvasRect.height;
gl.viewport(0, 0, canvasRect.width, canvasRect.height);
return gl;
}
/** create a WebGL program from vertex shader & fragment shader sources */
function createProgram({ gl, vShaderSrc, fShaderSrc }) {
// vertex shader
const vShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vShader, vShaderSrc);
gl.compileShader(vShader);
if (!gl.getShaderParameter(vShader, gl.COMPILE_STATUS)) {
throw new Error(`VECTOR SHADER: ${gl.getShaderInfoLog(vShader) || 'unknown'}`);
}
// fragment shader
const fShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fShader, fShaderSrc);
gl.compileShader(fShader);
if (!gl.getShaderParameter(fShader, gl.COMPILE_STATUS)) {
throw new Error(`FRAGMENT SHADER: ${gl.getShaderInfoLog(fShader) || 'unknown'}`);
}
// build program
const program = gl.createProgram();
gl.attachShader(program, vShader);
gl.attachShader(program, fShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program));
}
gl.useProgram(program);
return program;
}
// TODO: currently uses incorrect transfer function for Rec2020 (but OK for now since web doesn’t support Rec2020 :P)
/**
* Create a pixel-perfect gradient blend in Oklab space using WebGL
*/
const GRADIENT_RGB_SHADERS = {
attrs: {
a_position: 'a_position',
a_resolution: 'a_resolution',
a_start_color: 'a_start_color',
a_end_color: 'a_end_color',
},
vShader: '',
fShader: '',
};
GRADIENT_RGB_SHADERS.vShader = `#version 300 es
in vec4 ${GRADIENT_RGB_SHADERS.attrs.a_position};
in vec2 ${GRADIENT_RGB_SHADERS.attrs.a_resolution};
in vec4 ${GRADIENT_RGB_SHADERS.attrs.a_start_color};
in vec4 ${GRADIENT_RGB_SHADERS.attrs.a_end_color};
out vec4 v_start_color;
out vec4 v_end_color;
out vec2 v_resolution;
void main() {
gl_Position = ${GRADIENT_RGB_SHADERS.attrs.a_position};
v_resolution = ${GRADIENT_RGB_SHADERS.attrs.a_resolution};
v_start_color = ${GRADIENT_RGB_SHADERS.attrs.a_start_color};
v_end_color = ${GRADIENT_RGB_SHADERS.attrs.a_end_color};
}
`;
GRADIENT_RGB_SHADERS.fShader = `#version 300 es
precision highp float;
in vec2 v_resolution;
in vec4 v_start_color;
in vec4 v_end_color;
out vec4 f_color;
${LINEAR_RGB}
${OKLAB}
void main() {
float a = vec2(gl_FragCoord.xy / v_resolution).x;
f_color = linear_rgb_to_srgb(avg_vec4(oklab_to_linear_rgb(v_start_color), oklab_to_linear_rgb(v_end_color), a));
}
`;
/**
* Create a gradient from A to B, blended in Oklab space.
*/
class GradientOklab {
gl;
startColor;
endColor;
program;
attr = {
a_position: -1,
a_resolution: -1,
a_start_color: -1,
a_end_color: -1,
};
ro;
lastFrame;
constructor({ canvas, startColor, endColor }) {
this.gl = createRenderingContext(canvas);
this.program = createProgram({
gl: this.gl,
vShaderSrc: GRADIENT_RGB_SHADERS.vShader,
fShaderSrc: GRADIENT_RGB_SHADERS.fShader,
});
// get attribute locations
this.attr.a_position = this.gl.getAttribLocation(this.program, GRADIENT_RGB_SHADERS.attrs.a_position);
this.attr.a_resolution = this.gl.getAttribLocation(this.program, GRADIENT_RGB_SHADERS.attrs.a_resolution);
this.attr.a_start_color = this.gl.getAttribLocation(this.program, GRADIENT_RGB_SHADERS.attrs.a_start_color);
this.attr.a_end_color = this.gl.getAttribLocation(this.program, GRADIENT_RGB_SHADERS.attrs.a_end_color);
this.gl.enableVertexAttribArray(this.attr.a_position);
// keep canvas size up-to-date
this.ro = new ResizeObserver((entries) => {
this.setSize(entries[0].contentRect);
});
if (!(this.gl.canvas instanceof OffscreenCanvas)) {
this.ro.observe(this.gl.canvas);
}
// init attribs & first paint
this.startColor = startColor;
this.endColor = endColor;
this.setColors(startColor, endColor);
this.gl.vertexAttrib2f(this.attr.a_resolution, this.gl.canvas.width, this.gl.canvas.height);
this.render();
}
setColors(startColor, endColor) {
this.startColor = startColor;
this.endColor = endColor;
// note: `drawingBufferColorSpace` is ignored in Firefox, but it shouldn’t throw an error
this.gl.drawingBufferColorSpace = 'display-p3';
this.gl.vertexAttrib4f(this.attr.a_start_color, startColor.l, startColor.a, startColor.b, 1);
this.gl.vertexAttrib4f(this.attr.a_end_color, endColor.l, endColor.a, endColor.b, 1);
this.render();
}
setSize(rect) {
this.gl.vertexAttrib2f(this.attr.a_resolution, rect.width, rect.height);
this.gl.canvas.width = rect.width;
this.gl.canvas.height = rect.height;
this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
this.render();
}
/** render the canvas */
render() {
if (this.lastFrame) {
cancelAnimationFrame(this.lastFrame);
}
this.lastFrame = requestAnimationFrame(() => {
const positionBuffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
this.gl.vertexAttribPointer(this.attr.a_position, 2, this.gl.FLOAT, false, 0, 0);
// biome-ignore format: this is formatted
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1, // first triangle
-1, 1, 1, -1, 1, 1, // second triangle
]), this.gl.STATIC_DRAW);
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
this.lastFrame = undefined;
});
}
}
/**
* Generate a perceptually-uniform rainbow gradient in the Oklab space.
*/
const HUE_SHADERS = {
attrs: { a_position: 'a_position', a_resolution: 'a_resolution' },
vShader: '',
fShader: '',
};
HUE_SHADERS.vShader = `#version 300 es
precision highp float;
in vec4 ${HUE_SHADERS.attrs.a_position};
in vec2 ${HUE_SHADERS.attrs.a_resolution};
out vec2 v_resolution;
void main() {
gl_Position = ${HUE_SHADERS.attrs.a_position};
v_resolution = ${HUE_SHADERS.attrs.a_resolution};
}`;
HUE_SHADERS.fShader = `#version 300 es
precision highp float;
in vec2 v_resolution;
out vec4 f_color;
${LINEAR_RGB}
${OKLAB}
void main() {
// clamp_mode
// https://bottosson.github.io/posts/gamutclipping/
// 0 = no clamp (default)
// 1 = keep lightness, clamp chroma
// 2 = projection toward point, hue independent
// 3 = projection toward point, hue dependent
// 4 = adaptive Lightness, hue independent
// 5 = adaptive Lightness, hue dependent
int clamp_mode = 3;
float hue_norm = vec2(gl_FragCoord.xy / v_resolution).x;
float hue = 360.0 * hue_norm;
f_color = oklch_to_srgb(vec4(0.7, 0.4, hue, 1.0), clamp_mode);
}
`;
let HueWheel$1 = class HueWheel {
gl;
program;
gamut = 'srgb';
attr = { a_position: -1, a_resolution: -1 };
ro;
lastFrame;
constructor({ canvas, gamut = 'srgb' }) {
this.gl = createRenderingContext(canvas);
this.program = createProgram({ gl: this.gl, vShaderSrc: HUE_SHADERS.vShader, fShaderSrc: HUE_SHADERS.fShader });
// get attribute locations
this.attr.a_position = this.gl.getAttribLocation(this.program, HUE_SHADERS.attrs.a_position);
this.attr.a_resolution = this.gl.getAttribLocation(this.program, HUE_SHADERS.attrs.a_resolution);
this.gl.enableVertexAttribArray(this.attr.a_position);
// keep canvas size up-to-date
this.ro = new ResizeObserver((entries) => {
this.setSize(entries[0].contentRect);
});
if (!(this.gl.canvas instanceof OffscreenCanvas)) {
this.ro.observe(this.gl.canvas);
}
// init attribs & first paint
this.setGamut(gamut);
this.gl.vertexAttrib2f(this.attr.a_resolution, this.gl.canvas.width, this.gl.canvas.height);
this.render();
}
setGamut(gamut) {
if (gamut !== 'srgb' && gamut !== 'p3') {
throw new Error(`Unsupported gamut: "${gamut}"`);
}
this.gl.drawingBufferColorSpace = 'display-p3';
}
setSize(rect) {
this.gl.vertexAttrib2f(this.attr.a_resolution, rect.width, rect.height);
this.gl.canvas.width = rect.width;
this.gl.canvas.height = rect.height;
this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
this.render();
}
/** render the canvas */
render() {
if (this.lastFrame) {
cancelAnimationFrame(this.lastFrame);
}
this.lastFrame = requestAnimationFrame(() => {
const positionBuffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
this.gl.vertexAttribPointer(this.attr.a_position, 2, this.gl.FLOAT, false, 0, 0);
// biome-ignore format: this is formatted
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1, // first triangle
-1, 1, 1, -1, 1, 1, // second triangle
]), this.gl.STATIC_DRAW);
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
this.lastFrame = undefined;
});
}
};
function HueWheel({ ...rest }) {
const canvasEl = useRef(null);
const [webgl, setWebgl] = useState();
// initialize
useEffect(() => {
if (webgl || !canvasEl.current) {
return;
}
setWebgl(new HueWheel$1({ canvas: canvasEl.current }));
}, [webgl]);
return jsx("canvas", { ref: canvasEl, ...rest });
}
function TrueGradient({ start, end, ...props }) {
const canvasEl = useRef(null);
const [webgl, setWebgl] = useState();
// initialize
useEffect(() => {
if (webgl || !canvasEl.current) {
return;
}
setWebgl(new GradientOklab({ canvas: canvasEl.current, startColor: start, endColor: end }));
}, [webgl]);
// update color
useEffect(() => {
if (webgl) {
webgl.setColors(start, end);
}
}, [start, end, webgl]);
return jsx("canvas", { ...props, ref: canvasEl });
}
useMode(modeRgb);
useMode(modeLrgb);
const toOklab = useMode(modeOklab);
/** size, in px, to pad inner track */
const TRACK_PADDING = 4;
/** CSS class to add to body */
const BODY_DRAGGING_CLASS = 'tz-color-channel-slider-is-grabbing';
/** Amount Shift key affects drag rate */
const SHIFT_FACTOR = 0.25;
const CHANNEL_LABEL = {
alpha: 'Alpha',
b: 'Blue', // note: conflicts with Lab “B”! Handle conditionally though
c: 'Chroma',
g: 'Green',
h: 'Hue',
l: 'Lightness',
r: 'Red',
s: 'Saturation',
v: 'Value',
};
const CHANNEL_PRECISION = 5;
const RGB_COLORSPACES = ['a98', 'lrgb', 'p3', 'rgb', 'prophoto', 'rec2020'];
// const SRGB_COLORSPACES = ['rgb', 'hsv', 'hsl', 'hwb'];
// const P3_COLORSPACES = ['p3'];
function isPerc(color, channel) {
if (RGB_COLORSPACES.includes(color.original.mode)) {
return true;
}
if (channel === 'l' || channel === 'c' || channel === 's' || channel === 'v' || channel === 'alpha') {
return true;
}
if (channel === 'h') {
return false;
}
return false;
}
function ColorChannelBG({ channel, color, displayMin, displayMax, min, max }) {
if (channel === 'h') {
return (jsx("div", { className: 'tz-color-channel-slider-bg-wrapper', children: jsx(HueWheel, { className: 'tz-color-channel-slider-bg' }) }));
}
if (channel === 'alpha') {
return (jsx("div", { className: 'tz-color-channel-slider-bg-wrapper', children: jsx("div", { className: 'tz-color-channel-slider-bg tz-color-channel-slider-bg__alpha', style: {
// don’t use "transparent" to prevent the “fade to black” problem that could exist in some browsers in higher colorspaces
'--left-color': formatCss({
...(RGB_COLORSPACES.includes(color.original.mode) ? color.original : color.oklab),
alpha: 0,
}),
'--right-color': formatCss({
...(RGB_COLORSPACES.includes(color.original.mode) ? color.original : color.oklab),
alpha: 1,
}),
} }) }));
}
const range = (displayMax ?? max) - (displayMin ?? min);
const leftColor = { ...color.original, [channel]: displayMin ?? min };
const rightColor = { ...color.original, [channel]: displayMax ?? max };
const leftOklab = useMemo(() => withAlpha(toOklab(leftColor)), [color.css]);
const rightOklab = useMemo(() => withAlpha(toOklab(rightColor)), [color.css]);
return (jsxs("div", { className: 'tz-color-channel-slider-bg-wrapper', children: [jsx(TrueGradient, { className: 'tz-color-channel-slider-bg', start: leftOklab, end: rightOklab }), typeof displayMin === 'number' && displayMin < min && (jsx("div", { className: 'tz-color-channel-slider-overlay tz-color-channel-slider-overlay__min', style: { '--width': `${(100 * (min - displayMin)) / range}%` } })), typeof displayMax === 'number' && displayMax > max && (jsx("div", { className: 'tz-color-channel-slider-overlay tz-color-channel-slider-overlay__max', style: { '--width': `${(100 * (displayMax - max)) / range}%` } }))] }));
}
function ColorChannelSlider({ channel, className, color,
// gamut = 'rgb',
setColor, }) {
const { min, max } = useMemo(() => calculateBounds(color.original, channel), [color.original, channel]);
return (jsx(Slider, { bg: jsx(ColorChannelBG, { channel: channel, color: color, min: min, max: max }), className: className, handleColor: color.css, label: CHANNEL_LABEL[channel] ?? channel, max: max, min: min, onChange: (newValue) => setColor({ ...color.original, [channel]: newValue }), percentage: isPerc(color, channel), step: 1 / 10 ** CHANNEL_PRECISION, value: color.original[channel] }));
}
const COLOR_PICKER_SPACES = {
rgb: 'RGB',
oklab: 'Oklab',
lab: 'Lab',
oklch: 'Oklch',
lch: 'Lch',
okhsl: 'Okhsl',
xyz50: 'XYZ (D50)',
xyz65: 'XYZ (D65)',
};
function ColorPicker({ className, color, setColor, ...rest }) {
const [codeColor, setCodeColor] = useState(color.css);
const [maxGamut] = useState('rgb');
const normalizedColorMode = useMemo(() => (['p3', 'rec2020', 'lrgb'].includes(color.original.mode) ? 'rgb' : color.original.mode), [color]);
useEffect(() => {
setCodeColor(color.css);
}, [...Object.values(color.original)]);
return (jsxs("div", { className: clsx('tz-color-picker', className), style: {
'--current-color': ['okhsl', 'okhsv'].includes(color.original.mode) ? formatCss(color.oklab) : color.css,
}, ...rest, children: [jsx("div", { className: 'tz-color-picker-preview', children: jsx("div", { className: 'tz-color-picker-swatch' }) }), jsx("div", { className: 'tz-color-picker-colorspace', children: jsx(Select, { value: normalizedColorMode, trigger: color.original.mode, onValueChange: (newValue) => {
if (newValue in COLORSPACES) {
setColor(updateColor(COLORSPACES[newValue].converter(color.original), maxGamut));
}
}, children: Object.entries(COLOR_PICKER_SPACES).map(([id, label]) => (jsx(SelectItem, { value: id, icon: jsx(ColorFilterOutline, {}), children: label }, id))) }) }), jsx("div", { className: 'tz-color-picker-sliders', children: channelOrder(color.original).map((channel) => {
if (channel === 'mode') {
return null;
}
return (jsx(ColorChannelSlider, { channel: channel, color: color, gamut: maxGamut, setColor: setColor }, channel));
}) }), jsx("div", { className: 'tz-color-picker-code', children: jsx("input", { type: 'text', className: 'tz-color-picker-code-input', value: codeColor, onChange: (evt) => {
setCodeColor(evt.target.value);
const parsed = parse(evt.currentTarget.value);
if (parsed) {
setColor(updateColor(parsed, maxGamut));
}
} }) })] }));
}
export { BODY_DRAGGING_CLASS, COLOR_PICKER_SPACES, ColorChannelSlider, GRADIENT_RGB_SHADERS, GradientOklab, HUE_SHADERS, HueWheel, LINEAR_RGB, OKHSL, OKLAB, SHIFT_FACTOR, TRACK_PADDING, TrueGradient, calculateBounds, channelOrder, createProgram, createRenderingContext, ColorPicker as default, updateColor };
//# sourceMappingURL=index.js.map