feat: image thumbnail + lightbox viewer

- Add ImageViewer reusable component with thumbnail and click-to-zoom
- PostCover: load ?w=1200 thumbnail, click to view full-size
- PostCard: display cover thumbnail (?thumb=400x300) in list view
- PostContent: inline images load ?w=800 thumbnail, click to zoom
- Add lightbox overlay styles with fade-in animation
- Add zoom cursor and hover effect for zoomable images
- Extend web-sys features for DOM image/lightbox manipulation
This commit is contained in:
xfy 2026-06-08 15:52:47 +08:00
parent 2c08c6c7fd
commit b6f41e74e7
7 changed files with 306 additions and 5 deletions

View File

@ -29,7 +29,7 @@ image = { version = "0.25", optional = true }
moka = { version = "0.12", features = ["future"], optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "HtmlDocument", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlScriptElement"] }
web-sys = { version = "0.3", features = ["Document", "Window", "HtmlDocument", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlScriptElement", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "HtmlButtonElement", "EventTarget", "HtmlElement"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"

127
input.css
View File

@ -323,6 +323,15 @@
height: auto;
}
.md-content-img-zoomable {
cursor: zoom-in;
transition: opacity 0.2s ease;
}
.md-content-img-zoomable:hover {
opacity: 0.9;
}
.md-content iframe {
max-width: 100%;
border-radius: var(--radius-paper);
@ -517,6 +526,124 @@
text-decoration: underline;
text-underline-offset: 0.3rem;
}
/* Image Viewer Lightbox */
.image-viewer-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
animation: fadeIn 0.2s ease;
}
.image-viewer-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.image-viewer-img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-paper);
}
.image-viewer-close {
position: absolute;
top: -2.5rem;
right: 0;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 1.5rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.image-viewer-close:hover {
background: rgba(255, 255, 255, 0.4);
}
/* Markdown Image Lightbox */
.md-image-lightbox-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
animation: fadeIn 0.2s ease;
}
.md-image-lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.md-image-lightbox-content img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-paper);
}
.md-image-lightbox-close {
position: absolute;
top: -2.5rem;
right: 0;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 1.5rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.md-image-lightbox-close:hover {
background: rgba(255, 255, 255, 0.4);
}
/* Post Card Cover Thumbnail */
.post-card-cover {
overflow: hidden;
border-radius: var(--radius-paper);
}
.post-card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.post-card-cover:hover img {
transform: scale(1.05);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Responsive */

View File

@ -0,0 +1,53 @@
use dioxus::prelude::*;
#[component]
pub fn ImageViewer(
src: String,
#[props(default = "?w=800".to_string())]
thumb_params: String,
#[props(default = "图片".to_string())]
alt: String,
#[props(default = false)]
lazy_load: bool,
) -> Element {
let mut is_open = use_signal(|| false);
let thumb_src = if src.contains('?') {
format!("{}&{}", src.split('?').next().unwrap_or(&src), thumb_params.trim_start_matches('?'))
} else {
format!("{}{}", src, thumb_params)
};
rsx! {
// Thumbnail
img {
class: "cursor-pointer transition-opacity hover:opacity-90",
src: "{thumb_src}",
alt: "{alt}",
loading: if lazy_load { "lazy" } else { "eager" },
onclick: move |_| is_open.set(true),
}
// Full-screen lightbox
if is_open() {
div {
class: "image-viewer-overlay",
onclick: move |_| is_open.set(false),
div {
class: "image-viewer-content",
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
img {
class: "image-viewer-img",
src: "{src}",
alt: "{alt}",
}
button {
class: "image-viewer-close",
onclick: move |_| is_open.set(false),
""
}
}
}
}
}
}

View File

@ -4,6 +4,7 @@ pub mod footer;
pub mod forms;
pub mod frontend_layout;
pub mod header;
pub mod image_viewer;
pub mod nav;
pub mod post;
pub mod post_card;

View File

@ -4,6 +4,9 @@ use dioxus::prelude::*;
pub fn PostContent(content_html: String) -> Element {
#[cfg(target_arch = "wasm32")]
use_effect(move || {
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
// Add copy buttons to all code blocks
@ -26,6 +29,107 @@ pub fn PostContent(content_html: String) -> Element {
}
}
}
// Add click-to-zoom for images in post content
if let Ok(images) = document.query_selector_all(".md-content img") {
for i in 0..images.length() {
if let Some(img) = images.item(i) {
let img_element = img.clone().dyn_into::<web_sys::HtmlImageElement>().unwrap();
// Skip if already processed
if img_element.get_attribute("data-zoom-enabled").is_some() {
continue;
}
img_element.set_attribute("data-zoom-enabled", "true").unwrap();
let document_clone = document.clone();
let original_src = img_element.src();
let alt = img_element.alt();
// Replace src with thumbnail version (add ?w=800)
let thumb_src = if original_src.contains('?') {
format!("{}&w=800", original_src)
} else {
format!("{}?w=800", original_src)
};
img_element.set_src(&thumb_src);
img_element.set_class_name("md-content-img-zoomable");
let closure = Closure::wrap(Box::new(move |_event: web_sys::MouseEvent| {
// Create overlay
let overlay = document_clone.create_element("div").unwrap();
overlay.set_class_name("md-image-lightbox-overlay");
// Create image container
let container = document_clone.create_element("div").unwrap();
container.set_class_name("md-image-lightbox-content");
// Create full-size image
let full_img = document_clone.create_element("img").unwrap();
let full_img_el = full_img.dyn_ref::<web_sys::HtmlImageElement>().unwrap();
full_img_el.set_src(&original_src);
full_img_el.set_alt(&alt);
// Create close button
let close_btn = document_clone.create_element("button").unwrap();
close_btn.set_class_name("md-image-lightbox-close");
close_btn.set_text_content(Some(""));
// Assemble
let _ = container.append_child(&full_img);
let _ = container.append_child(&close_btn);
let _ = overlay.append_child(&container);
let _ = document_clone.body().unwrap().append_child(&overlay);
// Prevent body scroll
document_clone.body().unwrap().set_attribute("style", "overflow: hidden;").unwrap();
// Click overlay background to close
let overlay_for_bg = overlay.clone();
let close_bg = Closure::wrap(Box::new(move |_evt: web_sys::MouseEvent| {
if let Some(parent) = overlay_for_bg.parent_node() {
let _ = parent.remove_child(&overlay_for_bg);
}
}) as Box<dyn FnMut(_)>);
overlay.add_event_listener_with_callback("click", close_bg.as_ref().unchecked_ref()).unwrap();
close_bg.forget();
// Click container stops propagation (so clicking image doesn't close)
let stop_prop = Closure::wrap(Box::new(move |evt: web_sys::MouseEvent| {
evt.stop_propagation();
}) as Box<dyn FnMut(_)>);
container.add_event_listener_with_callback("click", stop_prop.as_ref().unchecked_ref()).unwrap();
stop_prop.forget();
// Click close button to close
let overlay_for_btn = overlay.clone();
let close_btn_handler = Closure::wrap(Box::new(move |_evt: web_sys::MouseEvent| {
if let Some(parent) = overlay_for_btn.parent_node() {
let _ = parent.remove_child(&overlay_for_btn);
}
}) as Box<dyn FnMut(_)>);
close_btn.add_event_listener_with_callback("click", close_btn_handler.as_ref().unchecked_ref()).unwrap();
close_btn_handler.forget();
// Escape key to close
let overlay_for_key = overlay.clone();
let key_handler = Closure::wrap(Box::new(move |evt: web_sys::KeyboardEvent| {
if evt.key() == "Escape" {
if let Some(parent) = overlay_for_key.parent_node() {
let _ = parent.remove_child(&overlay_for_key);
}
}
}) as Box<dyn FnMut(_)>);
document_clone.add_event_listener_with_callback("keydown", key_handler.as_ref().unchecked_ref()).unwrap();
key_handler.forget();
}) as Box<dyn FnMut(_)>);
img.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref()).unwrap();
closure.forget();
}
}
}
}
}
});

View File

@ -1,13 +1,16 @@
use dioxus::prelude::*;
use crate::components::image_viewer::ImageViewer;
#[component]
pub fn PostCover(src: String) -> Element {
rsx! {
figure { class: "entry-cover",
img {
loading: "eager",
src: "{src}",
alt: "Cover image"
ImageViewer {
src: src.clone(),
thumb_params: "?w=1200",
alt: "封面图片".to_string(),
lazy_load: false,
}
}
}

View File

@ -1,6 +1,7 @@
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::components::image_viewer::ImageViewer;
use crate::models::post::Post;
use crate::router::Route;
@ -8,6 +9,7 @@ use crate::router::Route;
pub fn PostCard(post: Post) -> Element {
let post_slug = post.slug.clone();
let date_str = post.formatted_date();
let has_cover = post.cover_image.is_some();
rsx! {
article {
@ -15,6 +17,17 @@ pub fn PostCard(post: Post) -> Element {
Link {
class: "block group",
to: Route::PostDetail { slug: post_slug },
if has_cover {
div {
class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg",
ImageViewer {
src: post.cover_image.clone().unwrap_or_default(),
thumb_params: "?thumb=400x300",
alt: post.title.clone(),
lazy_load: true,
}
}
}
h2 {
class: "text-2xl font-bold leading-tight text-gray-900 dark:text-[#dadadb] group-hover:opacity-80 transition-opacity",
"{post.title}"