From 95f7c147f12108e231066193afa02a98d3054cad Mon Sep 17 00:00:00 2001 From: mat <27899617+mat-1@users.noreply.github.com> Date: Tue, 16 Jul 2024 03:07:23 -0500 Subject: [PATCH] Add color picker answer (#13) * Add color picker answer * set autocomplete=off for the inputs * add symbols to the regexes * tweak hsv and hsl regex * don't show wikipedia when searching 'color picker' --- src/engines/answer.rs | 1 + src/engines/answer/colorpicker.rs | 383 ++++++++++++++++++++++++++ src/engines/answer/wikipedia.rs | 52 ++-- src/engines/mod.rs | 2 + src/web/assets/scripts/colorpicker.js | 349 +++++++++++++++++++++++ src/web/assets/style.css | 76 +++++ src/web/mod.rs | 1 + 7 files changed, 847 insertions(+), 17 deletions(-) create mode 100644 src/engines/answer/colorpicker.rs create mode 100644 src/web/assets/scripts/colorpicker.js diff --git a/src/engines/answer.rs b/src/engines/answer.rs index dff5b48..1e239ee 100644 --- a/src/engines/answer.rs +++ b/src/engines/answer.rs @@ -1,3 +1,4 @@ +pub mod colorpicker; pub mod dictionary; pub mod fend; pub mod ip; diff --git a/src/engines/answer/colorpicker.rs b/src/engines/answer/colorpicker.rs new file mode 100644 index 0000000..7ac0ca4 --- /dev/null +++ b/src/engines/answer/colorpicker.rs @@ -0,0 +1,383 @@ +use maud::html; + +use crate::engines::{EngineResponse, SearchQuery}; + +use super::regex; + +pub fn request(query: &SearchQuery) -> EngineResponse { + let matched_colors = MatchedColorModel::new(&query.query); + + let rgb; + let cmyk; + let hsv; + let hsl; + + if let Some(rgb_) = matched_colors.rgb { + rgb = rgb_; + cmyk = rgb_to_cmyk(rgb); + hsv = rgb_to_hsv(rgb); + hsl = rgb_to_hsl(rgb); + } else if let Some(cmyk_) = matched_colors.cmyk { + cmyk = cmyk_; + rgb = cmyk_to_rgb(cmyk); + hsv = rgb_to_hsv(rgb); + hsl = rgb_to_hsl(rgb); + } else if let Some(hsv_) = matched_colors.hsv { + hsv = hsv_; + rgb = hsv_to_rgb(hsv); + cmyk = rgb_to_cmyk(rgb); + hsl = hsv_to_hsl(hsv); + } else if let Some(hsl_) = matched_colors.hsl { + hsl = hsl_; + rgb = hsv_to_rgb(hsl_to_hsv(hsl)); + cmyk = rgb_to_cmyk(rgb); + hsv = rgb_to_hsv(rgb); + } else { + return EngineResponse::new(); + } + + let (r, g, b) = rgb; + let (c, m, y, k) = cmyk; + let (hsv_h, hsv_s, hsv_v) = hsv; + let (hsl_h, hsl_s, hsl_l) = hsl; + + let hex_str = format!( + "#{:02x}{:02x}{:02x}", + (r * 255.) as u8, + (g * 255.) as u8, + (b * 255.) as u8 + ); + let rgb_str = format!( + "{}, {}, {}", + (r * 255.) as u8, + (g * 255.) as u8, + (b * 255.) as u8 + ); + let cmyk_str = format!( + "{:.0}%, {:.0}%, {:.0}%, {:.0}%", + c * 100., + m * 100., + y * 100., + k * 100. + ); + let hsv_str = format!( + "{:.0}°, {:.0}%, {:.0}%", + hsv_h * 360., + hsv_s * 100., + hsv_v * 100. + ); + let hsl_str = format!( + "{:.0}°, {:.0}%, {:.0}%", + hsl_h * 360., + hsl_s * 100., + hsl_l * 100. + ); + + let hue_picker_x = hsv_h * 100.; + let picker_x = hsv_s * 100.; + let picker_y = (1. - hsv_v) * 100.; + + let hue_css_color = format!("hsl({}, 100%, 50%)", hsv_h * 360.); + + // yes the design of this is absolutely nabbed from google's + EngineResponse::answer_html(html! { + div.answer-colorpicker { + div.answer-colorpicker-preview-container { + div.answer-colorpicker-preview style=(format!("background-color: {hex_str}")) {} + div.answer-colorpicker-canvas-container { + div.answer-colorpicker-picker-container { + div.answer-colorpicker-picker style=(format!("background-color: {hex_str}; left: {picker_x}%; top: {picker_y}%;")) {} + } + svg.answer-colorpicker-canvas { + defs { + linearGradient id="saturation" x1="0%" x2="100%" y1="0%" y2="0%" { + stop offset="0%" stop-color="#fff" {} + stop.answer-colorpicker-canvas-hue-svg offset="100%" stop-color=(hex_str) {} + } + linearGradient id="value" x1="0%" x2="0%" y1="0%" y2="100%" { + stop offset="0%" stop-color="#fff" {} + stop offset="100%" stop-color="#000" {} + } + } + // the .1 fixes a bug that's present at least on firefox that makes the + // rightmost column of pixels look wrong + rect width="100.1%" height="100%" fill="url(#saturation)" {} + rect width="100.1%" height="100%" fill="url(#value)" style="mix-blend-mode: multiply" {} + } + } + } + div.answer-colorpicker-slider-container { + div.answer-colorpicker-huepicker style=(format!("background-color: {hue_css_color}; left: {hue_picker_x}%")) {} + svg.answer-colorpicker-slider { + defs { + linearGradient id="hue" x1="0%" x2="100%" y1="0%" y2="0%" { + stop offset="0%" stop-color="#ff0000" {} + stop offset="16.666%" stop-color="#ffff00" {} + stop offset="33.333%" stop-color="#00ff00" {} + stop offset="50%" stop-color="#00ffff" {} + stop offset="66.666%" stop-color="#0000ff" {} + stop offset="83.333%" stop-color="#ff00ff" {} + stop offset="100%" stop-color="#ff0000" {} + } + } + rect width="100%" height="50%" y="25%" fill="url(#hue)" {} + } + } + div.answer-colorpicker-hex-input-container { + label for="answer-colorpicker-hex-input" { "HEX" } + div.answer-colorpicker-input-container { + input #answer-colorpicker-hex-input type="text" autocomplete="off" value=(hex_str) {} + } + } + div.answer-colorpicker-other-inputs { + div { + label for="answer-colorpicker-rgb-input" { "RGB" } + div.answer-colorpicker-input-container { + input #answer-colorpicker-rgb-input type="text" autocomplete="off" value=(rgb_str) {} + } + } + div { + label for="answer-colorpicker-cmyk-input" { "CMYK" } + div.answer-colorpicker-input-container { + input #answer-colorpicker-cmyk-input type="text" autocomplete="off" value=(cmyk_str) {} + } + } + div { + label for="answer-colorpicker-hsv-input" { "HSV" } + div.answer-colorpicker-input-container { + input #answer-colorpicker-hsv-input type="text" autocomplete="off" value=(hsv_str) {} + } + } + div { + label for="answer-colorpicker-hsl-input" { "HSL" } + div.answer-colorpicker-input-container { + input #answer-colorpicker-hsl-input type="text" autocomplete="off" value=(hsl_str) {} + } + } + } + } + script src="/scripts/colorpicker.js" {} + }) +} + +pub struct MatchedColorModel { + pub rgb: Option<(f64, f64, f64)>, + pub cmyk: Option<(f64, f64, f64, f64)>, + pub hsv: Option<(f64, f64, f64)>, + pub hsl: Option<(f64, f64, f64)>, +} +impl MatchedColorModel { + pub fn new(query: &str) -> Self { + let mut rgb = None; + let mut cmyk = None; + let mut hsv = None; + let mut hsl = None; + + if regex!("^color ?picker$").is_match(&query.to_lowercase()) { + // default to red + rgb = Some((1., 0., 0.)); + } else if let Some(caps) = regex!("^#?([0-9a-f]{6})$").captures(query) { + let hex_str = caps.get(1).unwrap().as_str(); + let hex_num = u32::from_str_radix(hex_str, 16).unwrap(); + let r = ((hex_num >> 16) & 0xff) as f64 / 255.; + let g = ((hex_num >> 8) & 0xff) as f64 / 255.; + let b = (hex_num & 0xff) as f64 / 255.; + + let r = r.clamp(0., 1.); + let g = g.clamp(0., 1.); + let b = b.clamp(0., 1.); + + rgb = Some((r, g, b)); + } else if let Some(caps) = + regex!("^rgb\\((\\d{1,3}), ?(\\d{1,3}), ?(\\d{1,3})\\)$").captures(query) + { + let r = caps + .get(1) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 255.; + let g = caps + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 255.; + let b = caps + .get(3) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 255.; + + let r = r.clamp(0., 1.); + let g = g.clamp(0., 1.); + let b = b.clamp(0., 1.); + + rgb = Some((r, g, b)); + } else if let Some(caps) = + regex!("^cmyk\\((\\d{1,3})%, ?(\\d{1,3})%, ?(\\d{1,3})%, ?(\\d{1,3})%\\)$") + .captures(query) + { + let c = caps + .get(1) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 100.; + let m = caps + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 100.; + let y = caps + .get(3) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 100.; + let k = caps + .get(4) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 100.; + + let c = c.clamp(0., 1.); + let m = m.clamp(0., 1.); + let y = y.clamp(0., 1.); + let k = k.clamp(0., 1.); + + cmyk = Some((c, m, y, k)); + } else if let Some(caps) = + regex!("^hsv\\((\\d{1,3})(?:°|deg|), ?(\\d{1,3})%, ?(\\d{1,3})%\\)$").captures(query) + { + let h = caps + .get(1) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 360.; + let s = caps + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 100.; + let v = caps + .get(3) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 100.; + + let h = h.clamp(0., 1.); + let s = s.clamp(0., 1.); + let v = v.clamp(0., 1.); + + hsv = Some((h, s, v)); + } else if let Some(caps) = + regex!("^hsl\\((\\d{1,3})(?:°|deg|), ?(\\d{1,3})%, ?(\\d{1,3})%\\)$").captures(query) + { + let h = caps + .get(1) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 360.; + let s = caps + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 100.; + let l = caps + .get(3) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or_default() + / 100.; + + let h = h.clamp(0., 1.); + let s = s.clamp(0., 1.); + let l = l.clamp(0., 1.); + + hsl = Some((h, s, l)); + } + + Self { + rgb, + cmyk, + hsv, + hsl, + } + } + + pub fn is_empty(&self) -> bool { + self.rgb.is_none() && self.cmyk.is_none() && self.hsv.is_none() && self.hsl.is_none() + } +} + +// this is the code from colorpicker.js ported to rust +// see that file for credits +fn hsv_to_hsl((h, s, v): (f64, f64, f64)) -> (f64, f64, f64) { + let l = v - (v * s) / 2.; + let m = f64::min(l, 1. - l); + (h, if m != 0. { (v - l) / m } else { 0. }, l) +} +fn hsl_to_hsv((h, s, l): (f64, f64, f64)) -> (f64, f64, f64) { + let v = s * f64::min(l, 1. - l) + l; + (h, if v != 0. { 2. - (2. * l) / v } else { 0. }, v) +} +fn hsv_to_rgb((h, s, v): (f64, f64, f64)) -> (f64, f64, f64) { + let f = |n: f64| { + let k = (n + h * 6.) % 6.; + v - v * s * f64::max(f64::min(k, 4. - k), 0.) + }; + (f(5.), f(3.), f(1.)) +} +fn rgb_to_hsv((r, g, b): (f64, f64, f64)) -> (f64, f64, f64) { + let v = f64::max(r, f64::max(g, b)); + let c = v - f64::min(r, f64::min(g, b)); + let h = if c != 0. { + if v == r { + (g - b) / c + } else if v == g { + 2. + (b - r) / c + } else { + 4. + (r - g) / c + } + } else { + 0. + }; + ( + if h < 0. { h + 6. } else { h } / 6., + if v != 0. { c / v } else { 0. }, + v, + ) +} +fn rgb_to_hsl((r, g, b): (f64, f64, f64)) -> (f64, f64, f64) { + let v = f64::max(r, f64::max(g, b)); + let c = v - f64::min(r, f64::min(g, b)); + let f = 1. - f64::abs(v + v - c - 1.); + let h = if c != 0. { + if v == r { + (g - b) / c + } else if v == g { + 2. + (b - r) / c + } else { + 4. + (r - g) / c + } + } else { + 0. + }; + ( + if h < 0. { h + 6. } else { h } / 6., + if f != 0. { c / f } else { 0. }, + (v + v - c) / 2., + ) +} +fn rgb_to_cmyk((r, g, b): (f64, f64, f64)) -> (f64, f64, f64, f64) { + let k = 1. - f64::max(r, f64::max(g, b)); + if k == 1. { + return (0., 0., 0., 1.); + } + let c = (1. - r - k) / (1. - k); + let m = (1. - g - k) / (1. - k); + let y = (1. - b - k) / (1. - k); + (c, m, y, k) +} +fn cmyk_to_rgb((c, m, y, k): (f64, f64, f64, f64)) -> (f64, f64, f64) { + let r = (1. - c) * (1. - k); + let g = (1. - m) * (1. - k); + let b = (1. - y) * (1. - k); + (r, g, b) +} diff --git a/src/engines/answer/wikipedia.rs b/src/engines/answer/wikipedia.rs index ed6ff7b..2c10de7 100644 --- a/src/engines/answer/wikipedia.rs +++ b/src/engines/answer/wikipedia.rs @@ -4,25 +4,43 @@ use maud::html; use serde::Deserialize; use url::Url; -use crate::engines::{EngineResponse, CLIENT}; +use crate::engines::{EngineResponse, RequestResponse, CLIENT}; -pub fn request(query: &str) -> reqwest::RequestBuilder { - CLIENT.get( - Url::parse_with_params( - "https://en.wikipedia.org/w/api.php", - &[ - ("format", "json"), - ("action", "query"), - ("prop", "extracts|pageimages"), - ("exintro", ""), - ("explaintext", ""), - ("redirects", "1"), - ("exsentences", "2"), - ("titles", query), - ], +use super::colorpicker; + +pub fn request(mut query: &str) -> RequestResponse { + if !colorpicker::MatchedColorModel::new(query).is_empty() { + // "color picker" is a wikipedia article but we only want to show the + // actual color picker answer + return RequestResponse::None; + } + + // adding "wikipedia" to the start or end of your query is common when you + // want to get a wikipedia article + if let Some(stripped_query) = query.strip_suffix(" wikipedia") { + query = stripped_query + } else if let Some(stripped_query) = query.strip_prefix("wikipedia ") { + query = stripped_query + } + + CLIENT + .get( + Url::parse_with_params( + "https://en.wikipedia.org/w/api.php", + &[ + ("format", "json"), + ("action", "query"), + ("prop", "extracts|pageimages"), + ("exintro", ""), + ("explaintext", ""), + ("redirects", "1"), + ("exsentences", "2"), + ("titles", query), + ], + ) + .unwrap(), ) - .unwrap(), - ) + .into() } #[derive(Debug, Deserialize)] diff --git a/src/engines/mod.rs b/src/engines/mod.rs index e23593e..ad9dc64 100644 --- a/src/engines/mod.rs +++ b/src/engines/mod.rs @@ -42,6 +42,7 @@ engines! { Fend = "fend", Ip = "ip", Notepad = "notepad", + ColorPicker = "colorpicker", Numbat = "numbat", Thesaurus = "thesaurus", Timezone = "timezone", @@ -70,6 +71,7 @@ engine_requests! { Fend => answer::fend::request, None, Ip => answer::ip::request, None, Notepad => answer::notepad::request, None, + ColorPicker => answer::colorpicker::request, None, Numbat => answer::numbat::request, None, Thesaurus => answer::thesaurus::request, parse_response, Timezone => answer::timezone::request, None, diff --git a/src/web/assets/scripts/colorpicker.js b/src/web/assets/scripts/colorpicker.js new file mode 100644 index 0000000..7edde30 --- /dev/null +++ b/src/web/assets/scripts/colorpicker.js @@ -0,0 +1,349 @@ +// some guy on stackoverflow wrote a bunch of codegolfed color space conversion functions so i +// stole them for this (except the cmyk functions, those were stolen from other places) + +// https://stackoverflow.com/a/54116681 +function hsvToHsl(h, s, v) { + const l = v - (v * s) / 2; + const m = Math.min(l, 1 - l); + return [h, m ? (v - l) / m : 0, l]; +} +function hslToHsv(h, s, l) { + let v = s * Math.min(l, 1 - l) + l; + return [h, v ? 2 - (2 * l) / v : 0, v]; +} + +// https://stackoverflow.com/a/54024653 +function hsvToRgb(h, s, v) { + let f = (n, k = (n + h / 60) % 6) => + v - v * s * Math.max(Math.min(k, 4 - k, 1), 0); + return [f(5), f(3), f(1)]; +} +// https://stackoverflow.com/a/54070620 +function rgbToHsv(r, g, b) { + let v = Math.max(r, g, b), + c = v - Math.min(r, g, b); + let h = + c && (v == r ? (g - b) / c : v == g ? 2 + (b - r) / c : 4 + (r - g) / c); + return [60 * (h < 0 ? h + 6 : h), v && c / v, v]; +} +// https://stackoverflow.com/a/54071699 +function rgbToHsl(r, g, b) { + let v = Math.max(r, g, b), + c = v - Math.min(r, g, b), + f = 1 - Math.abs(v + v - c - 1); + let h = + c && (v == r ? (g - b) / c : v == g ? 2 + (b - r) / c : 4 + (r - g) / c); + return [60 * (h < 0 ? h + 6 : h), f ? c / f : 0, (v + v - c) / 2]; +} + +// https://www.codeproject.com/Articles/4488/XCmyk-CMYK-to-RGB-Calculator-with-source-code +function rgbToCmyk(r, g, b) { + const k = 1 - Math.max(r, g, b); + if (k === 1) return [0, 0, 0, 1]; + const c = (1 - r - k) / (1 - k); + const m = (1 - g - k) / (1 - k); + const y = (1 - b - k) / (1 - k); + return [c, m, y, k]; +} +// https://stackoverflow.com/a/37643472 +function cmykToRgb(c, m, y, k) { + const r = (1 - c) * (1 - k); + const g = (1 - m) * (1 - k); + const b = (1 - y) * (1 - k); + return [r, g, b]; +} + +// used for making it so an input isn't modified if we just typed in it +let activeInput = null; +document.addEventListener("keydown", () => { + activeInput = document.activeElement; +}); +document.addEventListener("focusout", () => { + activeInput = null; + + // in case they set an input to an invalid value + updateColorPreview(); +}); + +const colorPickerEl = document.getElementsByClassName("answer-colorpicker")[0]; + +const canvasEl = colorPickerEl.getElementsByClassName( + "answer-colorpicker-canvas" +)[0]; +const canvasHueSvgEl = canvasEl.getElementsByClassName( + "answer-colorpicker-canvas-hue-svg" +)[0]; +const pickerEl = colorPickerEl.getElementsByClassName( + "answer-colorpicker-picker" +)[0]; +const previewEl = colorPickerEl.getElementsByClassName( + "answer-colorpicker-preview" +)[0]; +const sliderEl = colorPickerEl.getElementsByClassName( + "answer-colorpicker-slider" +)[0]; +const huepickerEl = colorPickerEl.getElementsByClassName( + "answer-colorpicker-huepicker" +)[0]; + +const hexInputEl = document.getElementById("answer-colorpicker-hex-input"); +const rgbInputEl = document.getElementById("answer-colorpicker-rgb-input"); +const cmykInputEl = document.getElementById("answer-colorpicker-cmyk-input"); +const hsvInputEl = document.getElementById("answer-colorpicker-hsv-input"); +const hslInputEl = document.getElementById("answer-colorpicker-hsl-input"); + +let hsv = parseHsv(hsvInputEl.value); +let hsl = parseHsl(hslInputEl.value); +let rgb = parseRgb(rgbInputEl.value); +let cmyk = parseCmyk(cmykInputEl.value); + +function clamp(n, min, max) { + return Math.max(min, Math.min(max, n)); +} + +function setHsv(h, s, v) { + h = clamp(h, 0, 360); + s = clamp(s, 0, 1); + v = clamp(v, 0, 1); + + hsv = [h, s, v]; + hsl = hsvToHsl(...hsv); + rgb = hsvToRgb(...hsv); + cmyk = rgbToCmyk(...rgb); + updateColorPreview(); +} +function setHsl(h, s, l) { + h = clamp(h, 0, 360); + s = clamp(s, 0, 1); + l = clamp(l, 0, 1); + + hsl = [h, s, l]; + hsv = hslToHsv(...hsl); + rgb = hsvToRgb(...hsv); + cmyk = rgbToCmyk(...rgb); + updateColorPreview(); +} +function setRgb(r, g, b) { + r = clamp(r, 0, 1); + g = clamp(g, 0, 1); + b = clamp(b, 0, 1); + + rgb = [r, g, b]; + hsl = rgbToHsl(...rgb); + hsv = hslToHsv(...hsl); + cmyk = rgbToCmyk(...rgb); + updateColorPreview(); +} +function setCmyk(c, m, y, k) { + c = clamp(c, 0, 1); + m = clamp(m, 0, 1); + y = clamp(y, 0, 1); + k = clamp(k, 0, 1); + + cmyk = [c, m, y, k]; + rgb = cmykToRgb(...cmyk); + hsl = rgbToHsl(...rgb); + hsv = rgbToHsv(...rgb); + updateColorPreview(); +} + +let mouseInCanvas = false; +function canvasMouseDown(clientX, clientY) { + activeInput = null; + updatePicker(clientX, clientY); + mouseInCanvas = true; +} +function canvasMouseMove(clientX, clientY) { + activeInput; + if (mouseInCanvas) updatePicker(clientX, clientY); +} +function canvasMouseUp() { + mouseInCanvas = false; +} +canvasEl.addEventListener("mousedown", (e) => { + canvasMouseDown(e.clientX, e.clientY); +}); +canvasEl.addEventListener("touchstart", (e) => { + canvasMouseDown(e.touches[0].clientX, e.touches[0].clientY); +}); +document.addEventListener("mouseup", () => { + canvasMouseUp(); +}); +document.addEventListener("touchend", () => { + canvasMouseUp(); +}); +document.addEventListener("mousemove", (e) => { + canvasMouseMove(e.clientX, e.clientY); +}); +document.addEventListener("touchmove", (e) => { + canvasMouseMove(e.touches[0].clientX, e.touches[0].clientY); +}); + +let mouseInSlider = false; +function sliderMouseDown(clientX) { + updateHuePicker(clientX); + mouseInSlider = true; +} +function sliderMouseMove(clientX) { + if (mouseInSlider) updateHuePicker(clientX); +} +function sliderMouseUp() { + mouseInSlider = false; +} +sliderEl.addEventListener("mousedown", (e) => { + sliderMouseDown(e.clientX); +}); +sliderEl.addEventListener("touchstart", (e) => { + sliderMouseDown(e.touches[0].clientX); +}); +huepickerEl.addEventListener("mousedown", (e) => { + sliderMouseDown(e.clientX); +}); +huepickerEl.addEventListener("touchstart", (e) => { + sliderMouseDown(e.touches[0].clientX); +}); +document.addEventListener("mouseup", () => { + sliderMouseUp(); +}); +document.addEventListener("touchend", () => { + sliderMouseUp(); +}); +document.addEventListener("mousemove", (e) => { + sliderMouseMove(e.clientX); +}); +document.addEventListener("touchmove", (e) => { + sliderMouseMove(e.touches[0].clientX); +}); + +function updatePicker(clientX, clientY) { + const rect = canvasEl.getBoundingClientRect(); + let x = clientX - rect.left; + let y = clientY - rect.top; + if (x < 0) x = 0; + if (y < 0) y = 0; + if (x > rect.width) x = rect.width; + if (y > rect.height) y = rect.height; + + pickerEl.style.left = `${(x / rect.width) * 100}%`; + pickerEl.style.top = `${(y / rect.height) * 100}%`; + + const hue = hsv[0]; + setHsv(hue, x / rect.width, 1 - y / rect.height); +} + +function updateHuePicker(clientX) { + const rect = sliderEl.getBoundingClientRect(); + let x = clientX - rect.left; + if (x < 0) x = 0; + if (x > rect.width) x = rect.width; + + huepickerEl.style.left = `${(x / rect.width) * 100}%`; + + const hue = (x / rect.width) * 360; + setHsv(hue, hsv[1], hsv[2]); +} + +function updateColorPreview() { + const [r, g, b] = rgb; + const [hue, saturation, value] = hsv; + + const color = `rgb(${r * 255}, ${g * 255}, ${b * 255})`; + pickerEl.style.backgroundColor = color; + previewEl.style.backgroundColor = color; + + const hueColor = `hsl(${hue}, 100%, 50%)`; + huepickerEl.style.backgroundColor = hueColor; + canvasHueSvgEl.style.setProperty("stop-color", hueColor); + + pickerEl.style.left = `${saturation * 100}%`; + pickerEl.style.top = `${(1 - value) * 100}%`; + + if (activeInput !== hexInputEl) { + hexInputEl.value = + "#" + + rgb + .map((c) => + Math.round(c * 255) + .toString(16) + .padStart(2, "0") + ) + .join(""); + } + if (activeInput !== rgbInputEl) { + rgbInputEl.value = rgb.map((c) => Math.round(c * 255)).join(", "); + } + if (activeInput !== cmykInputEl) { + const cmykPercent = cmyk.map((c) => Math.round(c * 100)); + cmykInputEl.value = `${cmykPercent[0]}%, ${cmykPercent[1]}%, ${cmykPercent[2]}%, ${cmykPercent[3]}%`; + } + if (activeInput !== hsvInputEl) { + const hAngle = Math.round(hsv[0]); + hsvInputEl.value = `${hAngle}°, ${Math.round(hsv[1] * 100)}%, ${Math.round( + hsv[2] * 100 + )}%`; + } + if (activeInput !== hslInputEl) { + hslInputEl.value = `${Math.round(hsl[0])}°, ${Math.round( + hsl[1] * 100 + )}%, ${Math.round(hsl[2] * 100)}%`; + } +} + +function parseHex(value) { + value = hexInputEl.value.replace("#", ""); + if (value.length === 6) { + const r = parseInt(value.slice(0, 2), 16) / 255; + const g = parseInt(value.slice(2, 4), 16) / 255; + const b = parseInt(value.slice(4, 6), 16) / 255; + return [r, g, b]; + } else if (value.length === 3) { + const r = parseInt(value[0] + value[0], 16) / 255; + const g = parseInt(value[1] + value[1], 16) / 255; + const b = parseInt(value[2] + value[2], 16) / 255; + return [r, g, b]; + } +} +function setFromHexInput() { + setRgb(...parseHex(hexInputEl.value)); +} +hexInputEl.addEventListener("input", setFromHexInput); + +function parseRgb(value) { + return value.split(",").map((c) => parseInt(c) / 255); +} +function setFromRgbInput() { + setRgb(...parseRgb(rgbInputEl.value)); +} +rgbInputEl.addEventListener("input", setFromRgbInput); + +function parseCmyk(value) { + return value.split(",").map((c) => parseInt(c) / 100); +} +function setFromCmykInput() { + setCmyk(...parseCmyk(cmykInputEl.value)); +} +cmykInputEl.addEventListener("input", setFromCmykInput); + +function parseHsv(value) { + value = hsvInputEl.value.split(",").map((c) => parseInt(c)); + value[1] /= 100; + value[2] /= 100; + return value; +} +function setFromHsvInput() { + setHsv(...parseHsv(hsvInputEl.value)); +} +hsvInputEl.addEventListener("input", setFromHsvInput); + +function parseHsl(value) { + value = hslInputEl.value.split(",").map((c) => parseInt(c)); + value[1] /= 100; + value[2] /= 100; + return value; +} +function setFromHslInput() { + setHsl(...parseHsl(hslInputEl.value)); +} +hslInputEl.addEventListener("input", setFromHslInput); + +updateColorPreview(); diff --git a/src/web/assets/style.css b/src/web/assets/style.css index 3308037..c785788 100644 --- a/src/web/assets/style.css +++ b/src/web/assets/style.css @@ -368,6 +368,82 @@ h3.answer-thesaurus-category-title { resize: none; } +.answer-colorpicker-preview-container { + display: flex; + height: 228px; +} +.answer-colorpicker-preview { + width: 204px; + max-width: 33%; +} +.answer-colorpicker-picker-container { + position: absolute; + pointer-events: none; + width: 100%; + height: 100%; +} +.answer-colorpicker-picker, +.answer-colorpicker-huepicker { + position: absolute; + width: 1rem; + height: 1rem; + transform: translate(-0.5rem, -0.5rem); + border-radius: 50%; + border: 2px solid #fff; + + touch-action: none; +} +.answer-colorpicker-canvas-container { + flex: 1; + position: relative; +} +.answer-colorpicker-canvas { + height: 100%; + width: 100%; + + touch-action: none; +} +.answer-colorpicker-slider-container { + margin: 1rem; + position: relative; + height: 1rem; + + touch-action: none; +} +.answer-colorpicker-slider { + height: 100%; + width: 100%; +} +.answer-colorpicker-huepicker { + transform: translate(-0.5rem, -50%); + top: 50%; +} +.answer-colorpicker label { + display: block; + width: fit-content; +} +.answer-colorpicker-hex-input-container { + text-align: center; + margin-bottom: 0.5rem; +} +.answer-colorpicker-hex-input-container label { + margin: 0 auto; +} +#answer-colorpicker-hex-input { + width: 100%; + text-align: center; +} +.answer-colorpicker-other-inputs { + display: flex; + gap: 0.5rem; +} +.answer-colorpicker-input-container { + display: flex; +} +.answer-colorpicker-other-inputs input { + width: 100%; +} + /* infobox */ .infobox { margin-bottom: 1rem; diff --git a/src/web/mod.rs b/src/web/mod.rs index a20b404..f0e606e 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -73,6 +73,7 @@ pub async fn run(config: Config) { "style.css", "script.js", "robots.txt", + "scripts/colorpicker.js", "themes/catppuccin-mocha.css", "themes/catppuccin-latte.css", "themes/nord-bluish.css",