use dioxus::prelude::*; #[derive(Debug, Clone, Copy, PartialEq)] enum FitMode { FitScreen, FitWidth, Actual, } impl FitMode { fn next(self) -> Self { match self { Self::FitScreen => Self::FitWidth, Self::FitWidth => Self::Actual, Self::Actual => Self::FitScreen, } } fn label(self) -> &'static str { match self { Self::FitScreen => "Fit", Self::FitWidth => "Width", Self::Actual => "100%", } } } #[component] pub fn ImageViewer( src: String, alt: String, on_close: EventHandler<()>, #[props(default)] on_prev: Option>, #[props(default)] on_next: Option>, ) -> Element { let mut zoom = use_signal(|| 1.0f64); let mut offset_x = use_signal(|| 0.0f64); let mut offset_y = use_signal(|| 0.0f64); let mut dragging = use_signal(|| false); let mut drag_start_x = use_signal(|| 0.0f64); let mut drag_start_y = use_signal(|| 0.0f64); let mut fit_mode = use_signal(|| FitMode::FitScreen); let z = *zoom.read(); let ox = *offset_x.read(); let oy = *offset_y.read(); let is_dragging = *dragging.read(); let zoom_pct = (z * 100.0) as u32; let current_fit = *fit_mode.read(); let transform = format!("translate({ox}px, {oy}px) scale({z})"); let cursor = if z > 1.0 { if is_dragging { "grabbing" } else { "grab" } } else { "default" }; // Compute image style based on fit mode let img_style = match current_fit { FitMode::FitScreen => format!( "transform: {transform}; cursor: {cursor}; max-width: 100%; max-height: 100%; object-fit: contain;" ), FitMode::FitWidth => { format!("transform: {transform}; cursor: {cursor}; width: 100%; object-fit: contain;") } FitMode::Actual => format!("transform: {transform}; cursor: {cursor};"), }; let on_wheel = move |e: WheelEvent| { e.prevent_default(); let delta = e.delta().strip_units(); let factor = if delta.y < 0.0 { 1.1 } else { 1.0 / 1.1 }; let new_zoom = (*zoom.read() * factor).clamp(0.1, 20.0); zoom.set(new_zoom); }; let on_mouse_down = move |e: MouseEvent| { if *zoom.read() > 1.0 { dragging.set(true); let coords = e.client_coordinates(); drag_start_x.set(coords.x - *offset_x.read()); drag_start_y.set(coords.y - *offset_y.read()); } }; let on_mouse_move = move |e: MouseEvent| { if *dragging.read() { let coords = e.client_coordinates(); offset_x.set(coords.x - *drag_start_x.read()); offset_y.set(coords.y - *drag_start_y.read()); } }; let on_mouse_up = move |_: MouseEvent| { dragging.set(false); }; let on_keydown = { move |evt: KeyboardEvent| match evt.key() { Key::Escape => on_close.call(()), Key::Character(ref c) if c == "+" || c == "=" => { let new_zoom = (*zoom.read() * 1.2).min(20.0); zoom.set(new_zoom); } Key::Character(ref c) if c == "-" => { let new_zoom = (*zoom.read() / 1.2).max(0.1); zoom.set(new_zoom); } Key::Character(ref c) if c == "0" => { zoom.set(1.0); offset_x.set(0.0); offset_y.set(0.0); fit_mode.set(FitMode::FitScreen); } Key::ArrowLeft => { if let Some(ref prev) = on_prev { prev.call(()); zoom.set(1.0); offset_x.set(0.0); offset_y.set(0.0); } } Key::ArrowRight => { if let Some(ref next) = on_next { next.call(()); zoom.set(1.0); offset_x.set(0.0); offset_y.set(0.0); } } _ => {} } }; let zoom_in = move |_| { let new_zoom = (*zoom.read() * 1.2).min(20.0); zoom.set(new_zoom); }; let zoom_out = move |_| { let new_zoom = (*zoom.read() / 1.2).max(0.1); zoom.set(new_zoom); }; let cycle_fit = move |_| { let next = fit_mode.read().next(); fit_mode.set(next); zoom.set(1.0); offset_x.set(0.0); offset_y.set(0.0); }; let has_prev = on_prev.is_some(); let has_next = on_next.is_some(); rsx! { div { class: "image-viewer-overlay", tabindex: "0", onkeydown: on_keydown, // Toolbar div { class: "image-viewer-toolbar", div { class: "image-viewer-toolbar-left", if has_prev { button { class: "iv-btn", onclick: move |_| { if let Some(ref prev) = on_prev { prev.call(()); zoom.set(1.0); offset_x.set(0.0); offset_y.set(0.0); } }, title: "Previous", "\u{25c0}" } } if has_next { button { class: "iv-btn", onclick: move |_| { if let Some(ref next) = on_next { next.call(()); zoom.set(1.0); offset_x.set(0.0); offset_y.set(0.0); } }, title: "Next", "\u{25b6}" } } } div { class: "image-viewer-toolbar-center", button { class: "iv-btn", onclick: cycle_fit, title: "Cycle fit mode", "{current_fit.label()}" } button { class: "iv-btn", onclick: zoom_out, title: "Zoom out", "\u{2212}" } span { class: "iv-zoom-label", "{zoom_pct}%" } button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" } } div { class: "image-viewer-toolbar-right", button { class: "iv-btn iv-close", onclick: move |_| on_close.call(()), title: "Close", "\u{2715}" } } } // Image canvas div { class: "image-viewer-canvas", onwheel: on_wheel, onmousedown: on_mouse_down, onmousemove: on_mouse_move, onmouseup: on_mouse_up, onclick: move |e: MouseEvent| { // Close on background click (not on image) e.stop_propagation(); }, img { src: "{src}", alt: "{alt}", style: "{img_style}", draggable: "false", onclick: move |e: MouseEvent| e.stop_propagation(), } } } } }