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 }
|
moka = { version = "0.12", features = ["future"], optional = true }
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[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 = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
|
|||||||
127
input.css
127
input.css
@ -323,6 +323,15 @@
|
|||||||
height: auto;
|
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 {
|
.md-content iframe {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: var(--radius-paper);
|
border-radius: var(--radius-paper);
|
||||||
@ -517,6 +526,124 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 0.3rem;
|
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 */
|
/* 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 forms;
|
||||||
pub mod frontend_layout;
|
pub mod frontend_layout;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
|
pub mod image_viewer;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod post;
|
pub mod post;
|
||||||
pub mod post_card;
|
pub mod post_card;
|
||||||
|
|||||||
@ -4,6 +4,9 @@ use dioxus::prelude::*;
|
|||||||
pub fn PostContent(content_html: String) -> Element {
|
pub fn PostContent(content_html: String) -> Element {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
if let Some(document) = window.document() {
|
if let Some(document) = window.document() {
|
||||||
// Add copy buttons to all code blocks
|
// 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 dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::image_viewer::ImageViewer;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PostCover(src: String) -> Element {
|
pub fn PostCover(src: String) -> Element {
|
||||||
rsx! {
|
rsx! {
|
||||||
figure { class: "entry-cover",
|
figure { class: "entry-cover",
|
||||||
img {
|
ImageViewer {
|
||||||
loading: "eager",
|
src: src.clone(),
|
||||||
src: "{src}",
|
thumb_params: "?w=1200",
|
||||||
alt: "Cover image"
|
alt: "封面图片".to_string(),
|
||||||
|
lazy_load: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus::router::components::Link;
|
use dioxus::router::components::Link;
|
||||||
|
|
||||||
|
use crate::components::image_viewer::ImageViewer;
|
||||||
use crate::models::post::Post;
|
use crate::models::post::Post;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
@ -8,6 +9,7 @@ use crate::router::Route;
|
|||||||
pub fn PostCard(post: Post) -> Element {
|
pub fn PostCard(post: Post) -> Element {
|
||||||
let post_slug = post.slug.clone();
|
let post_slug = post.slug.clone();
|
||||||
let date_str = post.formatted_date();
|
let date_str = post.formatted_date();
|
||||||
|
let has_cover = post.cover_image.is_some();
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
article {
|
article {
|
||||||
@ -15,6 +17,17 @@ pub fn PostCard(post: Post) -> Element {
|
|||||||
Link {
|
Link {
|
||||||
class: "block group",
|
class: "block group",
|
||||||
to: Route::PostDetail { slug: post_slug },
|
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 {
|
h2 {
|
||||||
class: "text-2xl font-bold leading-tight text-gray-900 dark:text-[#dadadb] group-hover:opacity-80 transition-opacity",
|
class: "text-2xl font-bold leading-tight text-gray-900 dark:text-[#dadadb] group-hover:opacity-80 transition-opacity",
|
||||||
"{post.title}"
|
"{post.title}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user