UNPKG

@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
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