1use dioxus::prelude::*;
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum ImageStatus {
11 #[default]
13 Loading,
14 Loaded,
16 Error,
18}
19
20#[derive(Clone, Debug, PartialEq, Default)]
22pub struct PreviewConfig {
23 pub visible: bool,
25 pub mask: Option<String>,
27 pub close_icon: Option<Element>,
29 pub scale: f32,
31 pub min_scale: f32,
33 pub max_scale: f32,
35}
36
37impl PreviewConfig {
38 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 pub fn with_mask(mut self, mask: impl Into<String>) -> Self {
52 self.mask = Some(mask.into());
53 self
54 }
55
56 pub fn without_mask(mut self) -> Self {
58 self.mask = None;
59 self
60 }
61}
62
63#[derive(Props, Clone, PartialEq)]
65pub struct ImageProps {
66 pub src: String,
68 #[props(optional)]
70 pub alt: Option<String>,
71 #[props(optional)]
73 pub width: Option<String>,
74 #[props(optional)]
76 pub height: Option<String>,
77 #[props(optional)]
79 pub fallback: Option<String>,
80 #[props(optional)]
82 pub placeholder: Option<Element>,
83 #[props(default = true)]
85 pub preview: bool,
86 #[props(optional)]
88 pub preview_config: Option<PreviewConfig>,
89 #[props(optional)]
91 pub on_load: Option<EventHandler<()>>,
92 #[props(optional)]
94 pub on_error: Option<EventHandler<()>>,
95 #[props(optional)]
97 pub class: Option<String>,
98 #[props(optional)]
100 pub style: Option<String>,
101 #[props(optional)]
103 pub image_class: Option<String>,
104 #[props(optional)]
106 pub image_style: Option<String>,
107}
108
109#[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 let mut status: Signal<ImageStatus> = use_signal(|| ImageStatus::Loading);
131 let mut current_src: Signal<String> = use_signal(|| src.clone());
133 let mut preview_visible: Signal<bool> = use_signal(|| false);
135
136 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 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 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 let open_preview = move |_| {
169 if preview {
170 preview_visible.set(true);
171 }
172 };
173
174 let close_preview = move |_| preview_visible.set(false);
176
177 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 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 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 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 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 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 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 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#[derive(Props, Clone, PartialEq)]
275struct ImagePreviewProps {
276 src: String,
277 alt: String,
278 config: PreviewConfig,
279 on_close: EventHandler<MouseEvent>,
280}
281
282#[component]
284fn ImagePreview(props: ImagePreviewProps) -> Element {
285 let ImagePreviewProps {
286 src,
287 alt,
288 config,
289 on_close,
290 } = props;
291
292 let mut scale: Signal<f32> = use_signal(|| config.scale);
294 let mut rotation: Signal<i32> = use_signal(|| 0);
296
297 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 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 let rotate_left = move |_| {
319 let curr = *rotation.read();
320 rotation.set(curr - 90);
321 };
322
323 let rotate_right = move |_| {
325 let curr = *rotation.read();
326 rotation.set(curr + 90);
327 };
328
329 let reset = {
331 let initial_scale = config.scale;
332 move |_| {
333 scale.set(initial_scale);
334 rotation.set(0);
335 }
336 };
337
338 let handle_keydown = {
340 let on_close = on_close.clone();
341 move |evt: Event<KeyboardData>| {
342 if evt.key() == Key::Escape {
343 }
346 let _ = &on_close; }
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 div {
361 class: "adui-image-preview-mask",
362 onclick: move |evt| on_close.call(evt),
363 }
364
365 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 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 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#[derive(Props, Clone, PartialEq)]
429pub struct ImagePreviewGroupProps {
430 pub items: Vec<ImagePreviewItem>,
432 #[props(default)]
434 pub visible: bool,
435 #[props(default)]
437 pub current: usize,
438 #[props(optional)]
440 pub on_visible_change: Option<EventHandler<bool>>,
441 #[props(optional)]
443 pub on_change: Option<EventHandler<usize>>,
444 #[props(optional)]
446 pub preview_config: Option<PreviewConfig>,
447}
448
449#[derive(Clone, Debug, PartialEq)]
451pub struct ImagePreviewItem {
452 pub src: String,
454 pub alt: Option<String>,
456}
457
458impl ImagePreviewItem {
459 pub fn new(src: impl Into<String>) -> Self {
461 Self {
462 src: src.into(),
463 alt: None,
464 }
465 }
466
467 pub fn with_alt(mut self, alt: impl Into<String>) -> Self {
469 self.alt = Some(alt.into());
470 self
471 }
472}
473
474#[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 let mut current_index: Signal<usize> = use_signal(|| current);
489
490 if *current_index.read() != current {
492 current_index.set(current);
493 }
494
495 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 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 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 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 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 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 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 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 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 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 if items.len() > 1 {
678 div { class: "adui-image-preview-counter",
679 "{idx + 1} / {items.len()}"
680 }
681 }
682
683 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}