Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0176fa480e5ba40eea5a39685a4f97896a6a6964
244 lines
7.7 KiB
Rust
244 lines
7.7 KiB
Rust
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<EventHandler<()>>,
|
|
#[props(default)] on_next: Option<EventHandler<()>>,
|
|
) -> 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(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|