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:
parent
2c08c6c7fd
commit
b6f41e74e7
@ -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
127
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 */
|
||||
|
||||
53
src/components/image_viewer.rs
Normal file
53
src/components/image_viewer.rs
Normal 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),
|
||||
"✕"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user