adui_dioxus/components/
color_picker.rs

1use crate::components::config_provider::use_config;
2use crate::components::form::use_form_item_control;
3#[cfg(target_arch = "wasm32")]
4use crate::components::interaction::as_pointer_event;
5use dioxus::events::PointerData;
6use dioxus::prelude::*;
7use serde_json::Value;
8use std::rc::Rc;
9#[cfg(target_arch = "wasm32")]
10use wasm_bindgen::JsCast;
11
12/// HSVA color model for internal state.
13#[derive(Clone, Copy, Debug, PartialEq)]
14struct Hsva {
15    h: f64, // 0..360
16    s: f64, // 0..1
17    v: f64, // 0..1
18    a: f64, // 0..1
19}
20
21/// Props for the ColorPicker (single color).
22#[derive(Props, Clone, PartialEq)]
23pub struct ColorPickerProps {
24    /// Controlled color string, e.g. `#RRGGBB` or `#RRGGBBAA`.
25    #[props(optional)]
26    pub value: Option<String>,
27    /// Uncontrolled initial value.
28    #[props(optional)]
29    pub default_value: Option<String>,
30    #[props(default)]
31    pub disabled: bool,
32    #[props(default)]
33    pub allow_clear: bool,
34    #[props(optional)]
35    pub class: Option<String>,
36    #[props(optional)]
37    pub style: Option<String>,
38    /// Fired on every change with hex string (empty when cleared).
39    #[props(optional)]
40    pub on_change: Option<EventHandler<String>>,
41    /// Fired when interaction completes (pointer up / input blur).
42    #[props(optional)]
43    pub on_change_complete: Option<EventHandler<String>>,
44}
45
46#[component]
47pub fn ColorPicker(props: ColorPickerProps) -> Element {
48    let ColorPickerProps {
49        value,
50        default_value,
51        disabled,
52        allow_clear,
53        class,
54        style,
55        on_change,
56        on_change_complete,
57    } = props;
58
59    let config = use_config();
60    let form_control = use_form_item_control();
61    let controlled = value.is_some();
62
63    let initial = resolve_color(value.clone(), &form_control, default_value.as_ref());
64    let color_state = use_signal(|| initial);
65    let mut text_value = use_signal(|| color_to_hex(initial.as_ref()));
66
67    // Sync external value changes.
68    {
69        let form_ctx = form_control.clone();
70        let prop_val = value.clone();
71        let mut color_signal = color_state.clone();
72        let mut text_signal = text_value.clone();
73        use_effect(move || {
74            let next = resolve_color(prop_val.clone(), &form_ctx, None);
75            color_signal.set(next);
76            text_signal.set(color_to_hex(next.as_ref()));
77        });
78    }
79
80    let is_disabled =
81        disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
82
83    let mut class_list = vec!["adui-color-picker".to_string()];
84    if is_disabled {
85        class_list.push("adui-color-picker-disabled".into());
86    }
87    if let Some(extra) = class {
88        class_list.push(extra);
89    }
90    let class_attr = class_list.join(" ");
91    let style_attr = style.unwrap_or_default();
92
93    let apply_color: Rc<dyn Fn(Option<Hsva>, bool)> = Rc::new({
94        let on_change_cb = on_change.clone();
95        let on_change_complete_cb = on_change_complete.clone();
96        let form_ctx_for_apply = form_control.clone();
97        let color_for_apply = color_state.clone();
98        let text_for_apply = text_value.clone();
99        move |next: Option<Hsva>, fire_change: bool| {
100            if !controlled {
101                let mut state = color_for_apply;
102                state.set(next);
103            }
104            let hex = color_to_hex(next.as_ref());
105            let mut text_state = text_for_apply;
106            text_state.set(hex.clone());
107
108            if let Some(ctx) = form_ctx_for_apply.as_ref() {
109                if hex.is_empty() {
110                    ctx.set_value(Value::Null);
111                } else {
112                    ctx.set_value(Value::String(hex.clone()));
113                }
114            }
115
116            if fire_change {
117                if let Some(cb) = on_change_cb.as_ref() {
118                    cb.call(hex.clone());
119                }
120            }
121            if let Some(cb) = on_change_complete_cb.as_ref() {
122                cb.call(hex);
123            }
124        }
125    });
126
127    let apply_for_input = apply_color.clone();
128    let apply_for_clear = apply_color.clone();
129
130    // 跟踪拖动状态
131    let dragging_sat = use_signal(|| false);
132    let dragging_hue = use_signal(|| false);
133    let dragging_alpha = use_signal(|| false);
134
135    // Derived values for UI rendering.
136    let current = color_state.read().clone();
137    let base_hue = current.map(|c| c.h).unwrap_or(0.0);
138    let hue_rgb = hsv_to_rgb(base_hue, 1.0, 1.0);
139    let sat_cursor = current
140        .map(|c| (c.s.clamp(0.0, 1.0), 1.0 - c.v.clamp(0.0, 1.0)))
141        .unwrap_or((0.0, 0.0));
142    let (sat_x, sat_y) = sat_cursor;
143    let _alpha_value = current.map(|c| c.a).unwrap_or(1.0);
144    let preview_css = color_to_css(current.as_ref());
145    let hue_gradient = "linear-gradient(90deg, red 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, red 100%)";
146    let alpha_gradient = format!(
147        "linear-gradient(90deg, rgba({},{},{},0) 0%, rgba({},{},{},1) 100%)",
148        hue_rgb.0, hue_rgb.1, hue_rgb.2, hue_rgb.0, hue_rgb.1, hue_rgb.2
149    );
150
151    let handle_sat_pointer = {
152        let apply_for_sat = apply_color.clone();
153        let color_signal = color_state.clone();
154        let mut is_dragging = dragging_sat.clone();
155        move |evt: Event<PointerData>| {
156            if is_disabled {
157                return;
158            }
159
160            // 只在按下或拖动时响应
161            let buttons = evt.held_buttons();
162            if !buttons.contains(dioxus::html::input_data::MouseButton::Primary) {
163                is_dragging.set(false);
164                return;
165            }
166
167            is_dragging.set(true);
168
169            // 获取元素相对坐标
170            let elem_coords = evt.element_coordinates();
171            let s = (elem_coords.x / 200.0).clamp(0.0, 1.0);
172            let v = 1.0 - (elem_coords.y / 150.0).clamp(0.0, 1.0);
173
174            let current_color = color_signal.read().clone();
175            let current_alpha = current_color.map(|c| c.a).unwrap_or(1.0);
176            let mut next = current_color.unwrap_or(Hsva {
177                h: base_hue,
178                s: 1.0,
179                v: 1.0,
180                a: current_alpha,
181            });
182            next.s = s;
183            next.v = v;
184            apply_for_sat(Some(next), true);
185        }
186    };
187
188    let handle_hue_pointer = {
189        let apply_for_hue = apply_color.clone();
190        let color_signal = color_state.clone();
191        let mut is_dragging = dragging_hue.clone();
192        move |evt: Event<PointerData>| {
193            if is_disabled {
194                return;
195            }
196
197            let buttons = evt.held_buttons();
198            if !buttons.contains(dioxus::html::input_data::MouseButton::Primary) {
199                is_dragging.set(false);
200                return;
201            }
202
203            is_dragging.set(true);
204
205            let elem_coords = evt.element_coordinates();
206            let ratio = (elem_coords.x / 200.0).clamp(0.0, 1.0);
207            let h = ratio * 360.0;
208
209            let current_color = color_signal.read().clone();
210            let current_alpha = current_color.map(|c| c.a).unwrap_or(1.0);
211            let mut next = current_color.unwrap_or(Hsva {
212                h,
213                s: 1.0,
214                v: 1.0,
215                a: current_alpha,
216            });
217            next.h = h;
218            apply_for_hue(Some(next), true);
219        }
220    };
221
222    let handle_alpha_pointer = {
223        let apply_for_alpha = apply_color.clone();
224        let color_signal = color_state.clone();
225        let mut is_dragging = dragging_alpha.clone();
226        move |evt: Event<PointerData>| {
227            if is_disabled {
228                return;
229            }
230
231            let buttons = evt.held_buttons();
232            if !buttons.contains(dioxus::html::input_data::MouseButton::Primary) {
233                is_dragging.set(false);
234                return;
235            }
236
237            is_dragging.set(true);
238
239            let elem_coords = evt.element_coordinates();
240            let ratio = (elem_coords.x / 200.0).clamp(0.0, 1.0);
241
242            let current_color = color_signal.read().clone();
243            let mut next = current_color.unwrap_or(Hsva {
244                h: base_hue,
245                s: 1.0,
246                v: 1.0,
247                a: ratio,
248            });
249            next.a = ratio;
250            apply_for_alpha(Some(next), true);
251        }
252    };
253
254    let handle_input = move |evt: Event<FormData>| {
255        if is_disabled {
256            return;
257        }
258        let text = evt.value();
259        text_value.set(text.clone());
260        let parsed = parse_color(&text);
261        apply_for_input(parsed, true);
262    };
263
264    let handle_clear = move |_| {
265        if is_disabled || !allow_clear {
266            return;
267        }
268        apply_for_clear(None, true);
269    };
270
271    rsx! {
272        div { class: "{class_attr}", style: "{style_attr}",
273            div { class: "adui-color-picker-preview",
274                style: "background:{preview_css};",
275            }
276            div { class: "adui-color-picker-controls",
277                div { class: "adui-color-picker-sat",
278                    style: "background: {hue_background(base_hue)};",
279                    onpointerdown: handle_sat_pointer.clone(),
280                    onpointermove: handle_sat_pointer,
281                    div { class: "adui-color-picker-sat-white" }
282                    div { class: "adui-color-picker-sat-black" }
283                    div { class: "adui-color-picker-sat-handle",
284                        style: format!("left:{:.2}%;top:{:.2}%;", sat_x * 100.0, sat_y * 100.0),
285                    }
286                }
287                div { class: "adui-color-picker-slider",
288                    style: "background:{hue_gradient};",
289                    onpointerdown: handle_hue_pointer.clone(),
290                    onpointermove: handle_hue_pointer,
291                }
292                div { class: "adui-color-picker-slider",
293                    style: format!("background:{alpha_gradient};"),
294                    onpointerdown: handle_alpha_pointer.clone(),
295                    onpointermove: handle_alpha_pointer,
296                }
297                div { class: "adui-color-picker-input-row",
298                    input {
299                        class: "adui-color-picker-input",
300                        value: "{text_value.read()}",
301                        disabled: is_disabled,
302                        oninput: handle_input,
303                    }
304                    if allow_clear {
305                        button { class: "adui-color-picker-clear", disabled: is_disabled, onclick: handle_clear, "Clear" }
306                    }
307                }
308            }
309        }
310    }
311}
312
313fn resolve_color(
314    value: Option<String>,
315    form_control: &Option<crate::components::form::FormItemControlContext>,
316    fallback: Option<&String>,
317) -> Option<Hsva> {
318    value
319        .or_else(|| {
320            form_control
321                .as_ref()
322                .and_then(|ctx| value_from_form(ctx.value()))
323        })
324        .or_else(|| fallback.cloned())
325        .and_then(|s| parse_color(&s))
326}
327
328fn value_from_form(val: Option<Value>) -> Option<String> {
329    match val {
330        Some(Value::String(s)) => Some(s),
331        _ => None,
332    }
333}
334
335fn parse_color(input: &str) -> Option<Hsva> {
336    let trimmed = input.trim();
337    if trimmed.is_empty() {
338        return None;
339    }
340    if !trimmed.starts_with('#') {
341        return None;
342    }
343    let hex = trimmed.trim_start_matches('#');
344    let (r, g, b, a) = match hex.len() {
345        6 => {
346            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
347            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
348            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
349            (r, g, b, 255)
350        }
351        8 => {
352            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
353            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
354            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
355            let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
356            (r, g, b, a)
357        }
358        _ => return None,
359    };
360    let (h, s, v) = rgb_to_hsv(r, g, b);
361    Some(Hsva {
362        h,
363        s,
364        v,
365        a: (a as f64 / 255.0).clamp(0.0, 1.0),
366    })
367}
368
369fn color_to_hex(hsva: Option<&Hsva>) -> String {
370    if let Some(color) = hsva {
371        let (r, g, b) = hsv_to_rgb(color.h, color.s, color.v);
372        if (color.a - 1.0).abs() < f64::EPSILON {
373            format!("#{:02X}{:02X}{:02X}", r, g, b)
374        } else {
375            let a = (color.a * 255.0).round() as u8;
376            format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
377        }
378    } else {
379        String::new()
380    }
381}
382
383fn color_to_css(hsva: Option<&Hsva>) -> String {
384    if let Some(color) = hsva {
385        let (r, g, b) = hsv_to_rgb(color.h, color.s, color.v);
386        format!("rgba({},{},{},{:.3})", r, g, b, color.a)
387    } else {
388        "transparent".into()
389    }
390}
391
392fn hue_background(h: f64) -> String {
393    let (r, g, b) = hsv_to_rgb(h, 1.0, 1.0);
394    format!("rgb({},{},{})", r, g, b)
395}
396
397fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (u8, u8, u8) {
398    let c = v * s;
399    let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
400    let m = v - c;
401    let (r1, g1, b1) = match (h / 60.0).floor() as i32 {
402        0 => (c, x, 0.0),
403        1 => (x, c, 0.0),
404        2 => (0.0, c, x),
405        3 => (0.0, x, c),
406        4 => (x, 0.0, c),
407        _ => (c, 0.0, x),
408    };
409    (
410        ((r1 + m) * 255.0).round() as u8,
411        ((g1 + m) * 255.0).round() as u8,
412        ((b1 + m) * 255.0).round() as u8,
413    )
414}
415
416fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
417    let r = r as f64 / 255.0;
418    let g = g as f64 / 255.0;
419    let b = b as f64 / 255.0;
420    let max = r.max(g).max(b);
421    let min = r.min(g).min(b);
422    let delta = max - min;
423
424    let h = if delta < f64::EPSILON {
425        0.0
426    } else if (max - r).abs() < f64::EPSILON {
427        60.0 * (((g - b) / delta) % 6.0)
428    } else if (max - g).abs() < f64::EPSILON {
429        60.0 * (((b - r) / delta) + 2.0)
430    } else {
431        60.0 * (((r - g) / delta) + 4.0)
432    };
433    let s = if max.abs() < f64::EPSILON {
434        0.0
435    } else {
436        delta / max
437    };
438    (if h < 0.0 { h + 360.0 } else { h }, s, max)
439}
440
441#[cfg(test)]
442mod color_picker_tests {
443    use super::*;
444
445    #[test]
446    fn parse_color_empty_string() {
447        assert_eq!(parse_color(""), None);
448        assert_eq!(parse_color("   "), None);
449    }
450
451    #[test]
452    fn parse_color_invalid_format() {
453        assert_eq!(parse_color("not-a-color"), None);
454        assert_eq!(parse_color("rgb(255,0,0)"), None);
455        assert_eq!(parse_color("#GGG"), None);
456    }
457
458    #[test]
459    fn parse_color_6_digit_hex() {
460        let result = parse_color("#FF0000");
461        assert!(result.is_some());
462        let color = result.unwrap();
463        assert!((color.h - 0.0).abs() < 1.0 || (color.h - 360.0).abs() < 1.0);
464        assert_eq!(color.a, 1.0);
465    }
466
467    #[test]
468    fn parse_color_8_digit_hex() {
469        let result = parse_color("#FF000080");
470        assert!(result.is_some());
471        let color = result.unwrap();
472        assert!((color.a - 0.5).abs() < 0.01);
473    }
474
475    #[test]
476    fn color_to_hex_with_alpha() {
477        let color = Hsva {
478            h: 0.0,
479            s: 1.0,
480            v: 1.0,
481            a: 0.5,
482        };
483        let hex = color_to_hex(Some(&color));
484        assert!(hex.starts_with('#'));
485        assert_eq!(hex.len(), 9); // #RRGGBBAA
486    }
487
488    #[test]
489    fn color_to_hex_without_alpha() {
490        let color = Hsva {
491            h: 0.0,
492            s: 1.0,
493            v: 1.0,
494            a: 1.0,
495        };
496        let hex = color_to_hex(Some(&color));
497        assert_eq!(hex.len(), 7); // #RRGGBB
498    }
499
500    #[test]
501    fn color_to_hex_none() {
502        assert_eq!(color_to_hex(None), "");
503    }
504
505    #[test]
506    fn color_to_css_with_color() {
507        let color = Hsva {
508            h: 0.0,
509            s: 1.0,
510            v: 1.0,
511            a: 0.5,
512        };
513        let css = color_to_css(Some(&color));
514        assert!(css.starts_with("rgba("));
515    }
516
517    #[test]
518    fn color_to_css_none() {
519        assert_eq!(color_to_css(None), "transparent");
520    }
521
522    #[test]
523    fn hsv_to_rgb_red() {
524        let (r, g, b) = hsv_to_rgb(0.0, 1.0, 1.0);
525        assert_eq!(r, 255);
526        assert_eq!(g, 0);
527        assert_eq!(b, 0);
528    }
529
530    #[test]
531    fn hsv_to_rgb_green() {
532        let (r, g, b) = hsv_to_rgb(120.0, 1.0, 1.0);
533        assert_eq!(r, 0);
534        assert_eq!(g, 255);
535        assert_eq!(b, 0);
536    }
537
538    #[test]
539    fn hsv_to_rgb_blue() {
540        let (r, g, b) = hsv_to_rgb(240.0, 1.0, 1.0);
541        assert_eq!(r, 0);
542        assert_eq!(g, 0);
543        assert_eq!(b, 255);
544    }
545
546    #[test]
547    fn rgb_to_hsv_red() {
548        let (h, s, v) = rgb_to_hsv(255, 0, 0);
549        assert!((h - 0.0).abs() < 1.0 || (h - 360.0).abs() < 1.0);
550        assert!((s - 1.0).abs() < 0.01);
551        assert!((v - 1.0).abs() < 0.01);
552    }
553
554    #[test]
555    fn rgb_to_hsv_green() {
556        let (h, s, v) = rgb_to_hsv(0, 255, 0);
557        assert!((h - 120.0).abs() < 1.0);
558        assert!((s - 1.0).abs() < 0.01);
559        assert!((v - 1.0).abs() < 0.01);
560    }
561
562    #[test]
563    fn rgb_to_hsv_black() {
564        let (_h, _s, v) = rgb_to_hsv(0, 0, 0);
565        assert!((v - 0.0).abs() < 0.01);
566    }
567
568    #[test]
569    fn rgb_to_hsv_white() {
570        let (_h, s, v) = rgb_to_hsv(255, 255, 255);
571        assert!((s - 0.0).abs() < 0.01);
572        assert!((v - 1.0).abs() < 0.01);
573    }
574}