@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 colorjs.io.
1,099 lines (961 loc) • 33.2 kB
JavaScript
import { Select, SelectItem, Slider } from "@terrazzo/tiles";
import { get, serialize, set, to } from "colorjs.io/fn";
import { useEffect, useMemo, useRef, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import { ColorFilterOutline } from "@terrazzo/icons";
import clsx from "clsx";
//#region src/lib/oklab.ts
/**
* 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) {
float cusp_l = cusp.x;
float cusp_c = cusp.y;
// Find the intersection for upper and lower half separately
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(oklab.y * oklab.y + oklab.z * oklab.z));
float L0 = oklab.x;
float a = oklab.y / c;
float b = oklab.z / c;
vec2 cusp = find_cusp(a, b);
// 1. keep lightness, clamp chroma
if (clamp_mode == 1) {
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(e1 * e1 - 2.0 * abs(Ld))));
}
// 5. adaptive Lightness, hue dependent
else if (clamp_mode == 5) {
float Ld = oklab.x - cusp.x;
float k = 2.0 * (Ld >= 0.0 ? 1.0 - cusp.x : cusp.x);
float e1 = 0.5 * k + abs(Ld) + alpha * c / k;
L0 = cusp.x + 0.5 * (sign(Ld) * (e1 - sqrt(e1 * e1 - 2.0 * k * abs(Ld))));
}
float t = find_gamut_intersection(a, b, oklab.x, c, L0, cusp);
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);
float t = (k_3 * x - k_1);
return 0.5 * (k_3 * x - k_1 + sqrt(t * t + 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));
}
vec2 to_ST(vec2 cusp) {
float L = cusp.x;
float C = cusp.y;
return vec2(C / L, C / (1.0 - L));
}
vec4 okhsv_to_srgb(vec4 hsv) {
float h = hsv.x;
float s = hsv.y;
float v = hsv.z;
float alpha = hsv.w;
float a_ = cos(2.0 * pi * h);
float b_ = sin(2.0 * pi * h);
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 compute L and V as if the gamut is a perfect triangle:
// L, C when v==1:
float denom = (S_0 + T_max - T_max * k * s);
float L_v = 1.0 - s * S_0 / denom;
float C_v = s * T_max * S_0 / denom;
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_rgb(vec4(L_vt, a_ * C_vt, b_ * C_vt, alpha));
float scale_L = cbrt(1.0 / max(max(rgb_scale.x, rgb_scale.y), max(rgb_scale.z, 0.0)));
L = L * scale_L;
C = C * scale_L;
vec4 rgb = oklab_to_linear_rgb(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_rgb_to_oklab(vec4(
srgb_transfer_function_inv(rgb.x),
srgb_transfer_function_inv(rgb.y),
srgb_transfer_function_inv(rgb.z),
rgb.w
));
float C = sqrt(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 * atan2(-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_rgb(vec4(L_vt, a_ * C_vt, b_ * C_vt, rgb.w));
float scale_L = cbrt(1.0 / max(max(rgb_scale.x, rgb_scale.y), max(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.11516993 + 1.0 / (
+7.44778970 + 4.15901240 * b_
+ a_ * (-2.19557347 + 1.75198401 * b_
+ a_ * (-2.13704948 - 10.02301043 * b_
+ a_ * (-4.24894561 + 5.38770819 * b_ + 4.69891013 * a_
)))
);
float T = 0.11239642 + 1.0 / (
+1.61320320 - 0.68124379 * b_
+ a_ * (+0.40370612 + 0.90148123 * b_
+ a_ * (-0.27087943 + 0.61223990 * b_
+ a_ * (+0.00299215 - 0.45399568 * b_ - 0.14661872 * 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.0, L, cusp);
vec2 ST_max = to_ST(cusp);
// Scale factor to compensate for the curved part of gamut shape:
float k = C_max / min((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.9 * k * sqrt(sqrt(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 = sqrt(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_ = cos(2.0 * pi * h);
float b_ = sin(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_rgb(vec4(L, C * a_, C * b_, hsl.w));
return vec4(
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_rgb_to_oklab(vec4(
srgb_transfer_function_inv(rgb.x),
srgb_transfer_function_inv(rgb.y),
srgb_transfer_function_inv(rgb.z),
rgb.w
));
float C = sqrt(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 * atan2(-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);
}`;
//#endregion
//#region src/lib/rgb.ts
/** 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;
}
`;
//#endregion
//#region src/lib/webgl.ts
const TRIANGLES = new Float32Array([
-1,
-1,
1,
-1,
-1,
1,
-1,
1,
1,
-1,
1,
1
]);
/** create a WebGL2 rendering context and throw errors if needed */
function createRenderingContext(canvas) {
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 }) {
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"}`);
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"}`);
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;
}
/**
* 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.
*/
var GradientOklab = class {
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
});
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);
this.ro = new ResizeObserver((entries) => {
this.setSize(entries[0].contentRect);
});
if (!(this.gl.canvas instanceof OffscreenCanvas)) this.ro.observe(this.gl.canvas);
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);
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);
this.render();
}
setColors(startColor, endColor) {
this.startColor = startColor;
this.endColor = endColor;
this.gl.drawingBufferColorSpace = "display-p3";
this.gl.vertexAttrib4f(this.attr.a_start_color, startColor.coords[0], startColor.coords[1], startColor.coords[2], 1);
this.gl.vertexAttrib4f(this.attr.a_end_color, endColor.coords[0], endColor.coords[1], endColor.coords[2], 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(() => {
this.gl.bufferData(this.gl.ARRAY_BUFFER, TRIANGLES, this.gl.STATIC_DRAW);
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
this.lastFrame = void 0;
});
}
};
/**
* 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);
}
`;
var HueWheel$1 = class {
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
});
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);
this.ro = new ResizeObserver((entries) => {
this.setSize(entries[0].contentRect);
});
if (!(this.gl.canvas instanceof OffscreenCanvas)) this.ro.observe(this.gl.canvas);
this.setGamut(gamut);
this.gl.vertexAttrib2f(this.attr.a_resolution, this.gl.canvas.width, this.gl.canvas.height);
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);
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(() => {
this.gl.bufferData(this.gl.ARRAY_BUFFER, TRIANGLES, this.gl.STATIC_DRAW);
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
this.lastFrame = void 0;
});
}
};
//#endregion
//#region src/components/HueWheel.tsx
function HueWheel({ ...rest }) {
const canvasEl = useRef(null);
const [webgl, setWebgl] = useState();
useEffect(() => {
if (webgl || !canvasEl.current) return;
setWebgl(new HueWheel$1({ canvas: canvasEl.current }));
}, [webgl]);
return /* @__PURE__ */ jsx("canvas", {
ref: canvasEl,
...rest
});
}
//#endregion
//#region src/components/TrueGradient.tsx
function TrueGradient({ start, end, ...props }) {
const canvasEl = useRef(null);
const [webgl, setWebgl] = useState();
useEffect(() => {
if (webgl || !canvasEl.current) return;
setWebgl(new GradientOklab({
canvas: canvasEl.current,
startColor: start,
endColor: end
}));
}, [webgl]);
useEffect(() => {
if (webgl) webgl.setColors(start, end);
}, [
start,
end,
webgl
]);
return /* @__PURE__ */ jsx("canvas", {
...props,
ref: canvasEl
});
}
//#endregion
//#region src/components/ColorChannelSlider.tsx
/** 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 = .25;
const CHANNEL_PRECISION = 5;
function isPerc(color, channel) {
if (channel === "alpha") return true;
const lower = color.coords[channel]?.range?.[0];
const upper = color.coords[channel]?.range?.[1];
return lower === 0 && (upper === 1 || upper === 100);
}
function ColorChannelBG({ channel, color, displayMin, displayMax, min, max }) {
if ([
"hsl",
"lch",
"okhsl",
"okhsv",
"oklch"
].includes(color.original.space.id) && channel === "h") return /* @__PURE__ */ jsx("div", {
className: "tz-color-channel-slider-bg-wrapper",
children: /* @__PURE__ */ jsx(HueWheel, { className: "tz-color-channel-slider-bg" })
});
if (channel === "alpha") return /* @__PURE__ */ jsx("div", {
className: "tz-color-channel-slider-bg-wrapper",
children: /* @__PURE__ */ jsx("div", {
className: "tz-color-channel-slider-bg tz-color-channel-slider-bg__alpha",
style: {
"--left-color": serialize({
...color.oklab,
alpha: 0
}),
"--right-color": serialize({
...color.oklab,
alpha: 1
})
}
})
});
const range = (displayMax ?? max) - (displayMin ?? min);
return /* @__PURE__ */ jsxs("div", {
className: "tz-color-channel-slider-bg-wrapper",
children: [
/* @__PURE__ */ jsx(TrueGradient, {
className: "tz-color-channel-slider-bg",
start: useMemo(() => {
return to(set({
space: color.original.space,
coords: [...color.original.coords],
alpha: 1
}, channel, displayMin ?? min), "oklab", { inGamut: { space: "p3" } });
}, [
color,
channel,
displayMin,
min
]),
end: useMemo(() => {
return to(set({
space: color.original.space,
coords: [...color.original.coords],
alpha: 1
}, channel, displayMax ?? max), "oklab", { inGamut: { space: "p3" } });
}, [
color,
channel,
displayMax,
max
])
}),
typeof displayMin === "number" && displayMin < min && /* @__PURE__ */ 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 && /* @__PURE__ */ 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, setColor }) {
const [min, max] = color.original.space.coords[channel]?.range ?? color.original.space.coords[channel]?.refRange ?? [0, 1];
return /* @__PURE__ */ jsx(Slider, {
bg: /* @__PURE__ */ jsx(ColorChannelBG, {
channel,
color,
min,
max
}),
className,
handleColor: color.css,
label: color.original.space.coords[channel]?.name ?? channel,
max,
min,
onChange: (newValue) => {
setColor(set(color.original, channel, newValue));
},
percentage: isPerc(color.original.space, channel),
step: 1 / 10 ** CHANNEL_PRECISION,
value: get(color.original, channel)
});
}
//#endregion
//#region src/components/ColorPicker.tsx
const COLOR_PICKER_SPACES = {
srgb: "sRGB",
oklab: "Oklab",
lab: "Lab",
oklch: "OkLCh",
lch: "LCH",
okhsl: "Okhsl",
"xyz-d50": "XYZ (D50)",
"xyz-d65": "XYZ (D65)"
};
function ColorPicker({ className, color, colorSpaces, setColor, ...rest }) {
const [codeColor, setCodeColor] = useState(color.css);
const [maxGamut] = useState("srgb");
const normalizedColorMode = useMemo(() => [
"p3",
"rec2020",
"srgb-linear"
].includes(color.original.space.id) ? "srgb" : color.original.space.id, [color]);
const selectableColorSpaces = useMemo(() => {
if (!colorSpaces) return Object.entries(COLOR_PICKER_SPACES);
return Object.entries(COLOR_PICKER_SPACES).filter(([id]) => colorSpaces.includes(id));
}, [colorSpaces]);
const selectedColorMode = useMemo(() => selectableColorSpaces.some(([id]) => id === normalizedColorMode) ? normalizedColorMode : selectableColorSpaces[0]?.[0], [normalizedColorMode, selectableColorSpaces]);
useEffect(() => {
setCodeColor(color.css);
}, [color.css]);
return /* @__PURE__ */ jsxs("div", {
className: clsx("tz-color-picker", className),
style: { "--current-color": ["okhsl", "okhsv"].includes(color.original.space.id) ? serialize(color.oklab) : color.css },
...rest,
children: [
/* @__PURE__ */ jsx("div", {
className: "tz-color-picker-preview",
children: /* @__PURE__ */ jsx("div", { className: "tz-color-picker-swatch" })
}),
/* @__PURE__ */ jsx("div", {
className: "tz-color-picker-colorspace",
children: /* @__PURE__ */ jsx(Select, {
value: selectedColorMode,
trigger: color.original.space.id,
onValueChange: (colorSpace) => {
setColor(to(color.original, colorSpace, { inGamut: { space: maxGamut } }));
},
children: selectableColorSpaces.map(([id, label]) => /* @__PURE__ */ jsx(SelectItem, {
value: id,
icon: /* @__PURE__ */ jsx(ColorFilterOutline, {}),
children: label
}, id))
})
}),
/* @__PURE__ */ jsx("div", {
className: "tz-color-picker-sliders",
children: Object.keys(color.original.space.coords).map((channel) => {
return /* @__PURE__ */ jsx(ColorChannelSlider, {
channel,
color,
setColor
}, channel);
})
}),
/* @__PURE__ */ jsx("div", {
className: "tz-color-picker-code",
children: /* @__PURE__ */ jsx("input", {
type: "text",
className: "tz-color-picker-code-input",
value: codeColor,
onChange: (evt) => setColor(evt.target.value)
})
})
]
});
}
//#endregion
export { BODY_DRAGGING_CLASS, COLOR_PICKER_SPACES, ColorChannelSlider, GRADIENT_RGB_SHADERS, GradientOklab, HUE_SHADERS, HueWheel, LINEAR_RGB, OKHSL, OKLAB, SHIFT_FACTOR, TRACK_PADDING, TrueGradient, createProgram, createRenderingContext, ColorPicker as default };
//# sourceMappingURL=index.js.map