adui_dioxus/components/
image.rs

1//! Image component with preview support.
2//!
3//! An enhanced image component that supports loading states, fallback images,
4//! and an interactive preview modal with zoom and navigation capabilities.
5
6use dioxus::prelude::*;
7
8/// Image loading status.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum ImageStatus {
11    /// Image is loading.
12    #[default]
13    Loading,
14    /// Image loaded successfully.
15    Loaded,
16    /// Image failed to load.
17    Error,
18}
19
20/// Preview configuration for the Image component.
21#[derive(Clone, Debug, PartialEq, Default)]
22pub struct PreviewConfig {
23    /// Whether preview is enabled.
24    pub visible: bool,
25    /// Mask element or text to show on hover.
26    pub mask: Option<String>,
27    /// Custom close icon.
28    pub close_icon: Option<Element>,
29    /// Initial scale for preview.
30    pub scale: f32,
31    /// Minimum scale.
32    pub min_scale: f32,
33    /// Maximum scale.
34    pub max_scale: f32,
35}
36
37impl PreviewConfig {
38    /// Create a default preview configuration.
39    pub fn new() -> Self {
40        Self {
41            visible: true,
42            mask: Some("Preview".into()),
43            close_icon: None,
44            scale: 1.0,
45            min_scale: 0.5,
46            max_scale: 3.0,
47        }
48    }
49
50    /// Builder method to set mask text.
51    pub fn with_mask(mut self, mask: impl Into<String>) -> Self {
52        self.mask = Some(mask.into());
53        self
54    }
55
56    /// Builder method to disable mask.
57    pub fn without_mask(mut self) -> Self {
58        self.mask = None;
59        self
60    }
61}
62
63/// Props for the Image component.
64#[derive(Props, Clone, PartialEq)]
65pub struct ImageProps {
66    /// Image source URL.
67    pub src: String,
68    /// Alt text for the image.
69    #[props(optional)]
70    pub alt: Option<String>,
71    /// Width of the image.
72    #[props(optional)]
73    pub width: Option<String>,
74    /// Height of the image.
75    #[props(optional)]
76    pub height: Option<String>,
77    /// Fallback image source if main source fails.
78    #[props(optional)]
79    pub fallback: Option<String>,
80    /// Placeholder element shown while loading.
81    #[props(optional)]
82    pub placeholder: Option<Element>,
83    /// Whether to enable preview on click.
84    #[props(default = true)]
85    pub preview: bool,
86    /// Preview configuration.
87    #[props(optional)]
88    pub preview_config: Option<PreviewConfig>,
89    /// Callback when image loads successfully.
90    #[props(optional)]
91    pub on_load: Option<EventHandler<()>>,
92    /// Callback when image fails to load.
93    #[props(optional)]
94    pub on_error: Option<EventHandler<()>>,
95    /// Extra class for the root element.
96    #[props(optional)]
97    pub class: Option<String>,
98    /// Inline style for the root element.
99    #[props(optional)]
100    pub style: Option<String>,
101    /// Extra class for the image element.
102    #[props(optional)]
103    pub image_class: Option<String>,
104    /// Inline style for the image element.
105    #[props(optional)]
106    pub image_style: Option<String>,
107}
108
109/// Image component with loading states and preview support.
110#[component]
111pub fn Image(props: ImageProps) -> Element {
112    let ImageProps {
113        src,
114        alt,
115        width,
116        height,
117        fallback,
118        placeholder,
119        preview,
120        preview_config,
121        on_load,
122        on_error,
123        class,
124        style,
125        image_class,
126        image_style,
127    } = props;
128
129    // Loading status
130    let mut status: Signal<ImageStatus> = use_signal(|| ImageStatus::Loading);
131    // Current source (may switch to fallback)
132    let mut current_src: Signal<String> = use_signal(|| src.clone());
133    // Preview modal visibility
134    let mut preview_visible: Signal<bool> = use_signal(|| false);
135
136    // Handle load event
137    let handle_load = {
138        let on_load = on_load.clone();
139        move |_| {
140            status.set(ImageStatus::Loaded);
141            if let Some(handler) = &on_load {
142                handler.call(());
143            }
144        }
145    };
146
147    // Handle error event
148    let handle_error = {
149        let on_error = on_error.clone();
150        let fallback = fallback.clone();
151        let original_src = src.clone();
152        move |_| {
153            let curr = current_src.read().clone();
154            // If we haven't tried fallback yet and have one available
155            if curr == original_src && fallback.is_some() {
156                current_src.set(fallback.clone().unwrap());
157                status.set(ImageStatus::Loading);
158            } else {
159                status.set(ImageStatus::Error);
160                if let Some(handler) = &on_error {
161                    handler.call(());
162                }
163            }
164        }
165    };
166
167    // Open preview
168    let open_preview = move |_| {
169        if preview {
170            preview_visible.set(true);
171        }
172    };
173
174    // Close preview
175    let close_preview = move |_| preview_visible.set(false);
176
177    // Build wrapper classes
178    let mut class_list = vec!["adui-image".to_string()];
179    match *status.read() {
180        ImageStatus::Loading => class_list.push("adui-image-loading".into()),
181        ImageStatus::Loaded => class_list.push("adui-image-loaded".into()),
182        ImageStatus::Error => class_list.push("adui-image-error".into()),
183    }
184    if preview {
185        class_list.push("adui-image-preview-enabled".into());
186    }
187    if let Some(extra) = class {
188        class_list.push(extra);
189    }
190    let class_attr = class_list.join(" ");
191
192    // Build wrapper style
193    let mut style_parts = Vec::new();
194    if let Some(w) = &width {
195        style_parts.push(format!("width: {w};"));
196    }
197    if let Some(h) = &height {
198        style_parts.push(format!("height: {h};"));
199    }
200    if let Some(s) = style {
201        style_parts.push(s);
202    }
203    let style_attr = style_parts.join(" ");
204
205    // Build image classes
206    let mut img_class_list = vec!["adui-image-img".to_string()];
207    if let Some(extra) = image_class {
208        img_class_list.push(extra);
209    }
210    let img_class_attr = img_class_list.join(" ");
211    let img_style_attr = image_style.unwrap_or_default();
212
213    let current_src_val = current_src.read().clone();
214    let alt_text = alt.clone().unwrap_or_default();
215    let preview_cfg = preview_config.unwrap_or_else(PreviewConfig::new);
216
217    rsx! {
218        div { class: "{class_attr}", style: "{style_attr}",
219            // Placeholder shown while loading
220            if *status.read() == ImageStatus::Loading {
221                if let Some(ph) = placeholder {
222                    div { class: "adui-image-placeholder", {ph} }
223                } else {
224                    div { class: "adui-image-placeholder",
225                        div { class: "adui-image-placeholder-icon" }
226                    }
227                }
228            }
229
230            // Error state
231            if *status.read() == ImageStatus::Error {
232                div { class: "adui-image-error-content",
233                    span { class: "adui-image-error-icon", "⚠" }
234                    span { class: "adui-image-error-text", "Failed to load" }
235                }
236            }
237
238            // Main image
239            img {
240                class: "{img_class_attr}",
241                style: "{img_style_attr}",
242                src: "{current_src_val}",
243                alt: "{alt_text}",
244                onload: handle_load,
245                onerror: handle_error,
246                onclick: open_preview,
247            }
248
249            // Preview mask overlay
250            if preview && *status.read() == ImageStatus::Loaded {
251                if let Some(mask_text) = &preview_cfg.mask {
252                    div {
253                        class: "adui-image-mask",
254                        onclick: open_preview,
255                        span { class: "adui-image-mask-text", "{mask_text}" }
256                    }
257                }
258            }
259
260            // Preview modal
261            if *preview_visible.read() {
262                ImagePreview {
263                    src: current_src_val.clone(),
264                    alt: alt_text.clone(),
265                    config: preview_cfg.clone(),
266                    on_close: close_preview,
267                }
268            }
269        }
270    }
271}
272
273/// Props for the ImagePreview component.
274#[derive(Props, Clone, PartialEq)]
275struct ImagePreviewProps {
276    src: String,
277    alt: String,
278    config: PreviewConfig,
279    on_close: EventHandler<MouseEvent>,
280}
281
282/// Internal preview modal component.
283#[component]
284fn ImagePreview(props: ImagePreviewProps) -> Element {
285    let ImagePreviewProps {
286        src,
287        alt,
288        config,
289        on_close,
290    } = props;
291
292    // Zoom scale
293    let mut scale: Signal<f32> = use_signal(|| config.scale);
294    // Rotation
295    let mut rotation: Signal<i32> = use_signal(|| 0);
296
297    // Zoom in
298    let zoom_in = {
299        let max = config.max_scale;
300        move |_| {
301            let curr = *scale.read();
302            let next = (curr + 0.25).min(max);
303            scale.set(next);
304        }
305    };
306
307    // Zoom out
308    let zoom_out = {
309        let min = config.min_scale;
310        move |_| {
311            let curr = *scale.read();
312            let next = (curr - 0.25).max(min);
313            scale.set(next);
314        }
315    };
316
317    // Rotate left
318    let rotate_left = move |_| {
319        let curr = *rotation.read();
320        rotation.set(curr - 90);
321    };
322
323    // Rotate right
324    let rotate_right = move |_| {
325        let curr = *rotation.read();
326        rotation.set(curr + 90);
327    };
328
329    // Reset
330    let reset = {
331        let initial_scale = config.scale;
332        move |_| {
333            scale.set(initial_scale);
334            rotation.set(0);
335        }
336    };
337
338    // Close on escape key
339    let handle_keydown = {
340        let on_close = on_close.clone();
341        move |evt: Event<KeyboardData>| {
342            if evt.key() == Key::Escape {
343                // Create a synthetic mouse event for the close handler
344                // This is a workaround since we need to close but have a MouseEvent handler
345            }
346            let _ = &on_close; // Keep reference alive
347        }
348    };
349
350    let scale_val = *scale.read();
351    let rot_val = *rotation.read();
352    let transform_style = format!("transform: scale({}) rotate({}deg);", scale_val, rot_val);
353
354    rsx! {
355        div {
356            class: "adui-image-preview-root",
357            tabindex: "-1",
358            onkeydown: handle_keydown,
359            // Backdrop
360            div {
361                class: "adui-image-preview-mask",
362                onclick: move |evt| on_close.call(evt),
363            }
364
365            // Preview content
366            div { class: "adui-image-preview-wrap",
367                div { class: "adui-image-preview-body",
368                    img {
369                        class: "adui-image-preview-img",
370                        style: "{transform_style}",
371                        src: "{src}",
372                        alt: "{alt}",
373                    }
374                }
375
376                // Actions toolbar
377                div { class: "adui-image-preview-actions",
378                    button {
379                        class: "adui-image-preview-action",
380                        r#type: "button",
381                        onclick: zoom_out,
382                        title: "Zoom Out",
383                        "−"
384                    }
385                    button {
386                        class: "adui-image-preview-action",
387                        r#type: "button",
388                        onclick: zoom_in,
389                        title: "Zoom In",
390                        "+"
391                    }
392                    button {
393                        class: "adui-image-preview-action",
394                        r#type: "button",
395                        onclick: rotate_left,
396                        title: "Rotate Left",
397                        "↺"
398                    }
399                    button {
400                        class: "adui-image-preview-action",
401                        r#type: "button",
402                        onclick: rotate_right,
403                        title: "Rotate Right",
404                        "↻"
405                    }
406                    button {
407                        class: "adui-image-preview-action",
408                        r#type: "button",
409                        onclick: reset,
410                        title: "Reset",
411                        "⟲"
412                    }
413                }
414
415                // Close button
416                button {
417                    class: "adui-image-preview-close",
418                    r#type: "button",
419                    onclick: move |evt| on_close.call(evt),
420                    "×"
421                }
422            }
423        }
424    }
425}
426
427/// Props for the ImagePreviewGroup component.
428#[derive(Props, Clone, PartialEq)]
429pub struct ImagePreviewGroupProps {
430    /// List of image sources to preview.
431    pub items: Vec<ImagePreviewItem>,
432    /// Whether the group preview is visible.
433    #[props(default)]
434    pub visible: bool,
435    /// Current index in the group.
436    #[props(default)]
437    pub current: usize,
438    /// Callback when visibility changes.
439    #[props(optional)]
440    pub on_visible_change: Option<EventHandler<bool>>,
441    /// Callback when current index changes.
442    #[props(optional)]
443    pub on_change: Option<EventHandler<usize>>,
444    /// Preview configuration.
445    #[props(optional)]
446    pub preview_config: Option<PreviewConfig>,
447}
448
449/// A single item in the preview group.
450#[derive(Clone, Debug, PartialEq)]
451pub struct ImagePreviewItem {
452    /// Image source URL.
453    pub src: String,
454    /// Alt text.
455    pub alt: Option<String>,
456}
457
458impl ImagePreviewItem {
459    /// Create a new preview item.
460    pub fn new(src: impl Into<String>) -> Self {
461        Self {
462            src: src.into(),
463            alt: None,
464        }
465    }
466
467    /// Builder method to set alt text.
468    pub fn with_alt(mut self, alt: impl Into<String>) -> Self {
469        self.alt = Some(alt.into());
470        self
471    }
472}
473
474/// Group preview component for multiple images.
475#[component]
476pub fn ImagePreviewGroup(props: ImagePreviewGroupProps) -> Element {
477    let ImagePreviewGroupProps {
478        items,
479        visible,
480        current,
481        on_visible_change,
482        on_change,
483        preview_config,
484    } = props;
485
486    // Use props directly for controlled mode
487    // Internal state only for zoom/rotation
488    let mut current_index: Signal<usize> = use_signal(|| current);
489
490    // Sync current index with props when it changes
491    if *current_index.read() != current {
492        current_index.set(current);
493    }
494
495    // Zoom scale
496    let config = preview_config.unwrap_or_else(PreviewConfig::new);
497    let mut scale: Signal<f32> = use_signal(|| config.scale);
498    let mut rotation: Signal<i32> = use_signal(|| 0);
499
500    // Navigation
501    let go_prev = {
502        let items_len = items.len();
503        let on_change = on_change.clone();
504        move |_evt: MouseEvent| {
505            let curr = *current_index.read();
506            let prev = if curr == 0 { items_len - 1 } else { curr - 1 };
507            current_index.set(prev);
508            if let Some(handler) = &on_change {
509                handler.call(prev);
510            }
511        }
512    };
513
514    let go_next = {
515        let items_len = items.len();
516        let on_change = on_change.clone();
517        move |_evt: MouseEvent| {
518            let curr = *current_index.read();
519            let next = if curr + 1 >= items_len { 0 } else { curr + 1 };
520            current_index.set(next);
521            if let Some(handler) = &on_change {
522                handler.call(next);
523            }
524        }
525    };
526
527    // Close handler - just call the callback, parent controls visibility
528    let handle_close = {
529        let on_visible_change = on_visible_change.clone();
530        move |_evt: MouseEvent| {
531            if let Some(handler) = &on_visible_change {
532                handler.call(false);
533            }
534        }
535    };
536
537    // Keyboard navigation
538    let handle_keydown = {
539        let on_visible_change = on_visible_change.clone();
540        let on_change = on_change.clone();
541        let items_len = items.len();
542        move |evt: Event<KeyboardData>| match evt.key() {
543            Key::ArrowLeft => {
544                let curr = *current_index.read();
545                let prev = if curr == 0 { items_len - 1 } else { curr - 1 };
546                current_index.set(prev);
547                if let Some(handler) = &on_change {
548                    handler.call(prev);
549                }
550            }
551            Key::ArrowRight => {
552                let curr = *current_index.read();
553                let next = if curr + 1 >= items_len { 0 } else { curr + 1 };
554                current_index.set(next);
555                if let Some(handler) = &on_change {
556                    handler.call(next);
557                }
558            }
559            Key::Escape => {
560                if let Some(handler) = &on_visible_change {
561                    handler.call(false);
562                }
563            }
564            _ => {}
565        }
566    };
567
568    // Zoom controls
569    let zoom_in = {
570        let max = config.max_scale;
571        move |_| {
572            let curr = *scale.read();
573            scale.set((curr + 0.25).min(max));
574        }
575    };
576
577    let zoom_out = {
578        let min = config.min_scale;
579        move |_| {
580            let curr = *scale.read();
581            scale.set((curr - 0.25).max(min));
582        }
583    };
584
585    let rotate_left = move |_| {
586        let curr = *rotation.read();
587        rotation.set(curr - 90);
588    };
589
590    let rotate_right = move |_| {
591        let curr = *rotation.read();
592        rotation.set(curr + 90);
593    };
594
595    // Use visible prop directly for controlled visibility
596    if !visible || items.is_empty() {
597        return rsx! {};
598    }
599
600    let idx = *current_index.read();
601    let item = &items[idx.min(items.len() - 1)];
602    let scale_val = *scale.read();
603    let rot_val = *rotation.read();
604    let transform_style = format!("transform: scale({}) rotate({}deg);", scale_val, rot_val);
605
606    rsx! {
607        div {
608            class: "adui-image-preview-root adui-image-preview-group",
609            tabindex: "-1",
610            onkeydown: handle_keydown,
611
612            div {
613                class: "adui-image-preview-mask",
614                onclick: handle_close,
615            }
616
617            div { class: "adui-image-preview-wrap",
618                // Previous button
619                if items.len() > 1 {
620                    button {
621                        class: "adui-image-preview-nav adui-image-preview-nav-prev",
622                        r#type: "button",
623                        onclick: go_prev,
624                        "‹"
625                    }
626                }
627
628                // Image
629                div { class: "adui-image-preview-body",
630                    img {
631                        class: "adui-image-preview-img",
632                        style: "{transform_style}",
633                        src: "{item.src}",
634                        alt: "{item.alt.clone().unwrap_or_default()}",
635                    }
636                }
637
638                // Next button
639                if items.len() > 1 {
640                    button {
641                        class: "adui-image-preview-nav adui-image-preview-nav-next",
642                        r#type: "button",
643                        onclick: go_next,
644                        "›"
645                    }
646                }
647
648                // Actions toolbar
649                div { class: "adui-image-preview-actions",
650                    button {
651                        class: "adui-image-preview-action",
652                        r#type: "button",
653                        onclick: zoom_out,
654                        "−"
655                    }
656                    button {
657                        class: "adui-image-preview-action",
658                        r#type: "button",
659                        onclick: zoom_in,
660                        "+"
661                    }
662                    button {
663                        class: "adui-image-preview-action",
664                        r#type: "button",
665                        onclick: rotate_left,
666                        "↺"
667                    }
668                    button {
669                        class: "adui-image-preview-action",
670                        r#type: "button",
671                        onclick: rotate_right,
672                        "↻"
673                    }
674                }
675
676                // Counter
677                if items.len() > 1 {
678                    div { class: "adui-image-preview-counter",
679                        "{idx + 1} / {items.len()}"
680                    }
681                }
682
683                // Close button
684                button {
685                    class: "adui-image-preview-close",
686                    r#type: "button",
687                    onclick: handle_close,
688                    "×"
689                }
690            }
691        }
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn preview_config_builder() {
701        let config = PreviewConfig::new().with_mask("Click to preview");
702        assert_eq!(config.mask, Some("Click to preview".into()));
703        assert!(config.visible);
704
705        let no_mask = PreviewConfig::new().without_mask();
706        assert!(no_mask.mask.is_none());
707    }
708
709    #[test]
710    fn preview_item_builder() {
711        let item = ImagePreviewItem::new("test.jpg").with_alt("Test image");
712        assert_eq!(item.src, "test.jpg");
713        assert_eq!(item.alt, Some("Test image".into()));
714    }
715
716    #[test]
717    fn default_preview_config_values() {
718        let config = PreviewConfig::new();
719        assert_eq!(config.scale, 1.0);
720        assert_eq!(config.min_scale, 0.5);
721        assert_eq!(config.max_scale, 3.0);
722    }
723}