feat(assets): 素材网格接入灯箱预览
- 缩略图改为与前台正文图一致的 .blur-img 双层结构(?w=20 占位 + ?thumb=300x300 展示层 data-src),点击由全局注入的 lightbox.js 接管; 图集模式(当前页内 ←/→ 切换),灯箱加载原图(data-src 去 query)。 - 数据异步到达/筛选/翻页/刷新后由 use_effect 调 __initLightbox 重新绑定 (双保险契约同 PostContent:__lightboxSelectors + 直接调用兜底)。 - lightbox.ts 补 data-lb-bound 幂等守卫:Dioxus keyed diff 复用节点时 重复初始化会叠加 click 监听(点一次开两次);附回归测试。 - invoke_optional_global 提取为 src/utils/js.rs 共享,post_content 复用。 浏览器实测:47 张绑定齐全,原图打开/图集切换/Esc 关闭正常, 筛选重建与重建索引重取后重绑幂等(单次点击仅 append 一次 overlay)。
This commit is contained in:
parent
fa40f4bd95
commit
051ca947e3
@ -506,18 +506,28 @@ window.__initLightbox = (selectors: string | string[]): void => {
|
||||
initLazyLoad(node);
|
||||
}
|
||||
|
||||
// 幂等守卫:SPA 页面(如 /admin/assets)数据刷新后会重复调用 __initLightbox,
|
||||
// 而 Dioxus keyed diff 可能复用同一批 DOM 节点——无守卫会叠加 click 监听,
|
||||
// 点一次连续触发多次 openLightbox(飞行动画重启/闪烁)。
|
||||
// initLazyLoad 已有 data-blur-init 守卫,这里给 click 绑定补同款。
|
||||
const bindClick = (node: HTMLElement, handler: (e: MouseEvent) => void): void => {
|
||||
if (node.getAttribute('data-lb-bound')) return;
|
||||
node.setAttribute('data-lb-bound', 'true');
|
||||
node.addEventListener('click', handler);
|
||||
};
|
||||
|
||||
// 正文图:带 index。for..of + const 天然捕获每次迭代的 idx,
|
||||
// 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。
|
||||
const gallery = collected.gallery;
|
||||
gallery.forEach((node, idx) => {
|
||||
node.addEventListener('click', (e: MouseEvent) => {
|
||||
bindClick(node, (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openLightbox(node, gallery, idx);
|
||||
});
|
||||
});
|
||||
// 单张图(封面):index = null,gallery 传空数组表示单张
|
||||
for (const node of collected.singles) {
|
||||
node.addEventListener('click', (e: MouseEvent) => {
|
||||
bindClick(node, (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openLightbox(node, [], null);
|
||||
});
|
||||
|
||||
@ -237,6 +237,25 @@ describe('lightbox 黑盒行为', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('重复初始化幂等(SPA 数据刷新场景)', () => {
|
||||
it('同一批节点重复 __initLightbox,点击只创建一次 overlay', () => {
|
||||
const img = makeGalleryImage('/a.webp', 'A');
|
||||
mountRoot([img]);
|
||||
window.__initLightbox('.post-content');
|
||||
// 模拟 /admin/assets 刷新后 Dioxus 复用 DOM 节点导致的重复初始化
|
||||
window.__initLightbox('.post-content');
|
||||
|
||||
const appendSpy = vi.spyOn(document.body, 'appendChild');
|
||||
clickEl(img);
|
||||
// 无守卫时两个 click 监听先后触发 openLightbox → overlay 被 append 两次
|
||||
const overlayAppends = appendSpy.mock.calls.filter(
|
||||
(c) => c[0] instanceof HTMLElement && c[0].classList.contains('lightbox-overlay'),
|
||||
);
|
||||
expect(overlayAppends).toHaveLength(1);
|
||||
appendSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('单张图不参与图集切换', () => {
|
||||
it('单张图打开后按 ←/→ 不切换(无 counter、无箭头)', () => {
|
||||
const img = makeSingleImage('/cover.webp', '封面');
|
||||
|
||||
@ -12,6 +12,8 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::components::code_runner::CodeRunner;
|
||||
use crate::infra::runner_config::ResourceLimits;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::utils::js::invoke_optional_global;
|
||||
|
||||
/// 内容片段:普通 HTML 文本,或一个可运行代码块。
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
@ -92,27 +94,6 @@ fn decode_html_entities(s: &str) -> String {
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
/// 读取 `window` 上的可选全局函数并调用;函数未定义/为 null 时静默跳过。
|
||||
///
|
||||
/// 替代 `js_sys::eval("if(window.__x) window.__x(...)")` 字符串拼贴模式:用
|
||||
/// `Reflect::get` 取属性 + `Function::apply` 调用,无字符串求值,与 `tiptap_bridge`
|
||||
/// 的类型化 extern 风格一致。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn invoke_optional_global(window: &web_sys::Window, name: &str, args: &[wasm_bindgen::JsValue]) {
|
||||
use wasm_bindgen::JsCast;
|
||||
if let Ok(fn_val) = js_sys::Reflect::get(window, &name.into()) {
|
||||
if !fn_val.is_undefined() && !fn_val.is_null() {
|
||||
let arr = js_sys::Array::new();
|
||||
for a in args {
|
||||
arr.push(a);
|
||||
}
|
||||
let _ = fn_val
|
||||
.unchecked_into::<js_sys::Function>()
|
||||
.apply(window, &arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 文章内容组件。
|
||||
///
|
||||
/// Props:
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
//! 网格浏览 `uploads/` 已登记图片:搜索(文件名/alt)、引用状态筛选
|
||||
//! (全部/引用中/孤儿)、排序(最新/最大)、客户端分页。
|
||||
//! 缩略图直接复用 `serve_image` 的动态处理(`?thumb=300x300`),零额外成本。
|
||||
//!
|
||||
//! 缩略图采用与前台正文图一致的 `.blur-img` 双层结构(`?w=20` 占位 + `data-src`
|
||||
//! 展示层),点击由全局注入的 `lightbox.js` 接管为灯箱预览(图集模式,当前页内
|
||||
//! 左右切换,灯箱加载原图 = `data-src` 去 query)。数据异步到达/刷新后由
|
||||
//! `use_effect` 调 `__initLightbox` 绑定;TS 端有 `data-lb-bound` 守卫,重复绑定幂等。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
@ -17,6 +22,8 @@ use crate::api::assets::{PurgeOrphansResponse, RebuildAssetsResponse};
|
||||
use crate::components::empty_state::EmptyState;
|
||||
use crate::components::ui::{FilterTabs, Pagination};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::utils::js::invoke_optional_global;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::models::asset::{AssetFilter, AssetSort};
|
||||
|
||||
/// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。
|
||||
@ -103,6 +110,25 @@ pub fn Assets() -> Element {
|
||||
}
|
||||
});
|
||||
|
||||
// 灯箱初始化:lightbox.js 由 Dioxus.toml 全局注入。网格随数据异步渲染,
|
||||
// 需在数据到达(DOM 提交后)调 __initLightbox 绑定;筛选/翻页/刷新重建节点后
|
||||
// 重跑此 effect 重新绑定。TS 端 data-lb-bound 守卫保证重复绑定幂等。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use_effect(move || {
|
||||
// 订阅 data:取数/刷新后网格重渲染,effect 在 DOM 更新后运行。
|
||||
if data.read().is_none() {
|
||||
return;
|
||||
}
|
||||
let window =
|
||||
web_sys::window().expect("assets use_effect 仅在 WASM 浏览器上下文执行:无 window");
|
||||
// 双保险契约(同 PostContent):先设全局配置,lightbox.js 若尚未加载完,
|
||||
// 其 IIFE 尾部读到配置自启动;已加载则下方直接调用兜底。
|
||||
let selectors = js_sys::Array::of1(&".assets-lightbox".into());
|
||||
let selectors_val = js_sys::Object::from(selectors).into();
|
||||
let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
|
||||
invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
|
||||
});
|
||||
|
||||
let resp = data.read();
|
||||
let (assets, total, used_count, orphan_count, purgeable_count, purgeable_bytes) =
|
||||
match resp.as_ref() {
|
||||
@ -283,12 +309,14 @@ pub fn Assets() -> Element {
|
||||
description: "在编辑器中上传图片后会自动出现在这里".to_string(),
|
||||
}
|
||||
} else {
|
||||
// 网格:缩略图卡片
|
||||
div { class: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2",
|
||||
// 网格:缩略图卡片(assets-lightbox 为 __initLightbox 的根选择器)
|
||||
div { class: "assets-lightbox grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2",
|
||||
for asset in assets.iter() {
|
||||
{
|
||||
let a = &asset.asset;
|
||||
let thumb = format!("/uploads/{}?thumb=300x300", a.path);
|
||||
let placeholder = format!("/uploads/{}?w=20", a.path);
|
||||
let img_alt = a.alt.clone().unwrap_or_else(|| a.filename.clone());
|
||||
let is_orphan = asset.ref_count == 0;
|
||||
let badge_class = if is_orphan {
|
||||
"absolute top-2 left-2 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-amber-500/80 text-white"
|
||||
@ -299,12 +327,19 @@ pub fn Assets() -> Element {
|
||||
div {
|
||||
key: "{a.id}",
|
||||
class: "group relative rounded-3xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-sm hover:shadow-md transition-all",
|
||||
div { class: "aspect-square overflow-hidden bg-[var(--color-paper-theme)]",
|
||||
// blur-img 双层结构(对齐前台正文图):?w=20 模糊占位 +
|
||||
// data-src 展示层(IO 懒加载);点击由 lightbox.js 接管为灯箱
|
||||
// (图集模式,原图 = data-src 去 query)。不加 lightbox-single。
|
||||
div { class: "blur-img aspect-square m-0 cursor-pointer bg-[var(--color-paper-theme)]",
|
||||
img {
|
||||
class: "w-full h-full object-cover",
|
||||
src: "{thumb}",
|
||||
alt: a.alt.clone().unwrap_or_else(|| a.filename.clone()),
|
||||
loading: "lazy",
|
||||
class: "blur-img-placeholder",
|
||||
src: "{placeholder}",
|
||||
alt: "",
|
||||
}
|
||||
img {
|
||||
class: "blur-img-full",
|
||||
"data-src": "{thumb}",
|
||||
alt: "{img_alt}",
|
||||
}
|
||||
}
|
||||
// 引用徽标
|
||||
|
||||
28
src/utils/js.rs
Normal file
28
src/utils/js.rs
Normal file
@ -0,0 +1,28 @@
|
||||
//! WASM 端 JS 互操作小工具(调用 `window` 上的可选全局函数)。
|
||||
//!
|
||||
//! 仅在 `target_arch = "wasm32"` 下编译;服务端 SSR 路径不参与。
|
||||
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
/// 读取 `window` 上的可选全局函数并调用;函数未定义/为 null 时静默跳过。
|
||||
///
|
||||
/// 替代 `js_sys::eval("if(window.__x) window.__x(...)")` 字符串拼贴模式:用
|
||||
/// `Reflect::get` 取属性 + `Function::apply` 调用,无字符串求值,与 `tiptap_bridge`
|
||||
/// 的类型化 extern 风格一致。
|
||||
///
|
||||
/// 用于调用各 IIFE 库暴露的 `window.__init*` 入口(lightbox / mermaid / 锚点拦截等),
|
||||
/// 这些库由 `Dioxus.toml` 全局 `<script src>` 注入,加载时机不保证早于调用点。
|
||||
pub fn invoke_optional_global(window: &web_sys::Window, name: &str, args: &[wasm_bindgen::JsValue]) {
|
||||
use wasm_bindgen::JsCast;
|
||||
if let Ok(fn_val) = js_sys::Reflect::get(window, &name.into()) {
|
||||
if !fn_val.is_undefined() && !fn_val.is_null() {
|
||||
let arr = js_sys::Array::new();
|
||||
for a in args {
|
||||
arr.push(a);
|
||||
}
|
||||
let _ = fn_val
|
||||
.unchecked_into::<js_sys::Function>()
|
||||
.apply(window, &arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! - `comment_storage`:评论草稿 localStorage 持久化(WASM 端)。
|
||||
//! - `html`:HTML 转义(两端通用)。
|
||||
//! - `js`:WASM 端调用 `window.__init*` 可选全局函数(仅 wasm32)。
|
||||
//! - `text`:Markdown / 纯文本处理(仅 `server` feature)。
|
||||
//! - `time`:跨平台时间/睡眠工具(WASM 与原生异步版本)。
|
||||
|
||||
@ -9,6 +10,9 @@
|
||||
pub mod comment_storage;
|
||||
/// HTML 转义工具(前端后端通用)。
|
||||
pub mod html;
|
||||
/// WASM 端 JS 全局函数调用工具(仅 wasm32 编译)。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod js;
|
||||
/// Markdown / 纯文本处理工具。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod text;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user