yggdrasil/src/components/image_viewer.rs
xfy 975e331dd5 fix(write): split tags on fullwidth punctuation
The tag splitter matched ASCII ',' and ';' twice instead of the
fullwidth ',' (U+FF0C) / ';' (U+FF1B) the comment intended, so tags
separated by fullwidth punctuation (common under Chinese IMEs)
collapsed into a single tag.

Also resolves the compiler warnings surfaced in the same build:
- tiptap_bridge: drop unused Readable/Writable imports and
  EditorInstance/TiptapEditorModule re-exports
- tiptap_bridge: migrate deprecated RequestInit::method/body/
  credentials builders to set_method/set_body/set_credentials
- write: remove redundant `mut` on Dioxus signals that are only
  read/set, never reassigned
- image_viewer: gate the server-only `mut` reassignment so the WASM
  build (which strips the cfg block) doesn't warn
2026-06-23 14:44:18 +08:00

164 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 图片查看器组件
//!
//! 提供缩略图展示与点击放大后的全屏灯箱lightbox查看
//! 支持自定义缩略图参数、alt 文本与懒加载。
//! 灯箱支持键盘操作Escape 关闭、Tab 在关闭按钮与图片间循环。
use dioxus::prelude::*;
/// 图片查看器组件。
///
/// Props
/// - `src`:原图 URL
/// - `thumb_params`:缩略图 URL 参数,默认 `"?w=800"`
/// - `alt`:图片替代文本,默认 `"图片"`
/// - `lazy_load`:是否启用懒加载,默认 `false`
/// - `lightbox`:是否启用点击放大灯箱,默认 `true`。卡片等场景设为 `false`
/// 以禁用灯箱交互,使整张卡片归一为单一跳转行为(避免嵌套交互)。
///
/// 关键事件(仅在 `lightbox == true` 时生效):
/// - 点击缩略图:打开全屏灯箱
/// - 点击遮罩或关闭按钮:关闭灯箱
/// - 点击灯箱内容区:阻止事件冒泡,避免误关闭
/// - 键盘 Escape关闭灯箱
#[component]
pub fn ImageViewer(
src: String,
#[props(default = "?w=800".to_string())] thumb_params: String,
#[props(default = "?w=20".to_string())] placeholder_params: String,
#[props(default = "图片".to_string())] alt: String,
#[props(default = false)] lazy_load: bool,
#[props(default = true)] lightbox: bool,
) -> Element {
let mut is_open = use_signal(|| false);
// 打开灯箱时聚焦关闭按钮,并监听 Escape 键关闭。
// 灯箱禁用时跳过键盘监听与焦点管理,避免无谓的 DOM 操作。
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen::{closure::Closure, JsCast};
use_effect(move || {
if !lightbox || !is_open() {
return;
}
wasm_bindgen_futures::spawn_local(async move {
// 下一帧再聚焦,确保 DOM 已渲染。
let _ = js_sys::eval("new Promise(r => requestAnimationFrame(r))").unwrap();
if let Some(btn) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector(".image-viewer-close").ok())
.flatten()
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok())
{
let _ = btn.focus();
}
});
let closure = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
if e.key() == "Escape" {
is_open.set(false);
}
}) as Box<dyn FnMut(_)>);
if let Some(window) = web_sys::window() {
let _ = window.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
}
closure.forget();
});
}
// 计算 aspect-ratioSSR 时读图片真实尺寸。WASM 端不读(--ar 已在 SSR 写入 HTML
// 非 /uploads/ 的外链图或读不到尺寸时不设 --ar。
let ar_style = {
// `mut` 仅 server 构建需要WASM 构建剥离 #[cfg(feature = "server")] 块后,
// s 从未被重新赋值,故对 WASM 抑制 unused_mut。
#[cfg_attr(not(feature = "server"), allow(unused_mut))]
let mut s = String::new();
#[cfg(feature = "server")]
{
if let Some(rel) = src
.strip_prefix("/uploads/")
.map(|p| p.split('?').next().unwrap_or(p))
{
if let Some((w, h)) = crate::api::image::get_image_dimensions(rel) {
// 注意CSS aspect-ratio 用斜杠分隔width / height不是冒号
s = format!("--ar:{} / {};", w, h);
}
}
}
s
};
// 拼接占位图 URL 和高清图 URL
let placeholder_src = if src.contains('?') {
format!(
"{}&{}",
src.split('?').next().unwrap_or(&src),
placeholder_params.trim_start_matches('?')
)
} else {
format!("{}{}", src, placeholder_params)
};
let full_src = if src.contains('?') {
format!(
"{}&{}",
src.split('?').next().unwrap_or(&src),
thumb_params.trim_start_matches('?')
)
} else {
format!("{}{}", src, thumb_params)
};
rsx! {
// blur-up 双层:底层占位图 + 上层高清图data-src 由前端 JS 懒加载)
// 灯箱禁用时不绑定 onclick使缩略图成为纯展示元素卡片由外层覆盖链接接管跳转
span {
class: "blur-img",
style: "{ar_style}",
onclick: move |_| if lightbox { is_open.set(true) },
img {
class: "blur-img-placeholder",
src: "{placeholder_src}",
alt: "{alt}",
loading: if lazy_load { "lazy" } else { "eager" },
}
img {
class: "blur-img-full",
"data-src": "{full_src}",
alt: "{alt}",
}
}
// 全屏灯箱(仅 lightbox 启用时可能打开)
if lightbox && is_open() {
div {
class: "image-viewer-overlay",
role: "dialog",
aria_modal: "true",
aria_label: "图片预览",
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}",
tabindex: 0,
}
button {
class: "image-viewer-close",
r#type: "button",
aria_label: "关闭图片预览",
onclick: move |_| is_open.set(false),
""
}
}
}
}
}
}