From b6f41e74e79c8a41a09a9b8cee87a15b3b0ae04a Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 8 Jun 2026 15:52:47 +0800 Subject: [PATCH] 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 --- Cargo.toml | 2 +- input.css | 127 ++++++++++++++++++++++++++++ src/components/image_viewer.rs | 53 ++++++++++++ src/components/mod.rs | 1 + src/components/post/post_content.rs | 104 +++++++++++++++++++++++ src/components/post/post_cover.rs | 11 ++- src/components/post_card.rs | 13 +++ 7 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 src/components/image_viewer.rs diff --git a/Cargo.toml b/Cargo.toml index 7a43255..2ed9500 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/input.css b/input.css index 0849fd9..67a55e1 100644 --- a/input.css +++ b/input.css @@ -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 */ diff --git a/src/components/image_viewer.rs b/src/components/image_viewer.rs new file mode 100644 index 0000000..9915744 --- /dev/null +++ b/src/components/image_viewer.rs @@ -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), + "✕" + } + } + } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 116b971..54d1196 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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; diff --git a/src/components/post/post_content.rs b/src/components/post/post_content.rs index 35e24b1..838d32a 100644 --- a/src/components/post/post_content.rs +++ b/src/components/post/post_content.rs @@ -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::().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::().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); + 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); + 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); + 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); + document_clone.add_event_listener_with_callback("keydown", key_handler.as_ref().unchecked_ref()).unwrap(); + key_handler.forget(); + + }) as Box); + + img.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref()).unwrap(); + closure.forget(); + } + } + } } } }); diff --git a/src/components/post/post_cover.rs b/src/components/post/post_cover.rs index cc1d096..49e393f 100644 --- a/src/components/post/post_cover.rs +++ b/src/components/post/post_cover.rs @@ -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, } } } diff --git a/src/components/post_card.rs b/src/components/post_card.rs index 5c936da..14ce1e3 100644 --- a/src/components/post_card.rs +++ b/src/components/post_card.rs @@ -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}"