灯箱这类交互,可以用浏览器原生能力完成,不必为单一图片预览额外引入组件库。真正需要处理的不只是放大图片,还包括加载失败、键盘关闭、焦点去向,以及关闭后页面能否继续阅读。
点击图片试用主题内置灯箱先分清主题内置功能与独立示例
当前 Waxy 已经内置灯箱:正文图片经构建期处理后,通过带 data-lightbox 的链接打开原生 dialog。脚本调用 showModal(),把焦点放到关闭按钮,记录页面原有的 body.style.overflow,关闭时再恢复滚动状态和触发图片的链接焦点。
上面的本地图片使用的就是这条内置路径。图片文件放在站点 source/media/demo.jpg,构建后对应 /media/demo.jpg。不需要把下面的示例再次加到主题的 custom_js,否则容易产生两套控制器和两层弹窗。
第一步:让没有脚本的链接也能工作
把样式链接放入页面的 head,图片链接放入正文。外部脚本使用 defer,等 HTML 解析完成再安装交互。即使脚本没有加载,图片仍然是一个可以直接访问的正常链接。
<link rel="stylesheet" href="/css/demo-lightbox.css">
<a class="demo-lightbox-link" href="/media/demo.jpg">
<img src="/media/demo.jpg" alt="本地示例图片"
loading="lazy" decoding="async">
</a>
<script src="/js/demo-lightbox.js" defer></script>
缩略图的 src 和原图的 href 可以不同,例如正文使用较小的文件,弹窗再打开原图。示例为了容易复制,使用同一个本地地址。不要把真正地址只放到自定义 data-src 里,否则无脚本时图片本身就无法显示。
第二步:给原生 dialog 配一层样式
保存为 /css/demo-lightbox.css。只在 [open] 时设置布局,避免用普通 display 强行显示一个并未处于模态状态的对话框。
#demo-lightbox {
box-sizing: border-box;
width: 100vw;
height: 100vh;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 4rem 1rem 1rem;
border: 0;
color: #fff;
background: rgb(0 0 0 / 90%);
}
#demo-lightbox[open] { display: grid; place-items: center; }
#demo-lightbox::backdrop { background: rgb(0 0 0 / 50%); }
#demo-lightbox img {
max-width: 100%;
max-height: calc(100vh - 6rem);
max-height: calc(100dvh - 6rem);
object-fit: contain;
}
#demo-lightbox button {
position: fixed;
top: 1rem;
right: 1rem;
padding: .5rem 1rem;
border: 1px solid currentColor;
border-radius: .4rem;
color: inherit;
background: #222;
cursor: pointer;
}
#demo-lightbox button:focus-visible { outline: 2px solid #fff; }
第三步:安装完整的打开与关闭流程
保存为 /js/demo-lightbox.js。能力检查不通过时直接返回,不接管链接。普通左键和键盘激活才预览;Ctrl、Command、Shift、Alt、下载链接及指定其他窗口的链接继续交给浏览器。
(() => {
if (document.getElementById('demo-lightbox')) return;
const dialog = document.createElement('dialog');
if (typeof dialog.showModal !== 'function') return;
dialog.id = 'demo-lightbox';
dialog.setAttribute('aria-label', '图片预览');
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.textContent = '关闭图片预览';
closeButton.autofocus = true;
const status = document.createElement('p');
status.setAttribute('role', 'status');
dialog.append(closeButton, status);
document.body.append(dialog);
let opener = null;
let preview = null;
let previousOverflow = '';
function restore() {
if (!opener) return;
const target = opener;
opener = null;
if (preview) {
preview.removeAttribute('src');
preview.remove();
preview = null;
}
document.body.style.overflow = previousOverflow;
if (target.isConnected) target.focus({ preventScroll: true });
}
function close() {
if (!dialog.open) return;
dialog.close();
restore();
}
function open(link) {
if (dialog.open) return false;
opener = link;
previousOverflow = document.body.style.overflow;
try {
dialog.showModal();
} catch {
opener = null;
return false; // 保留原链接的默认打开方式。
}
document.body.style.overflow = 'hidden';
status.hidden = false;
status.textContent = '图片加载中…';
const image = new Image();
const thumbnail = link.querySelector('img');
image.alt = thumbnail ? thumbnail.alt : '';
image.hidden = true;
preview = image;
image.addEventListener('load', () => {
if (preview !== image || !dialog.open) return;
status.hidden = true;
image.hidden = false;
});
image.addEventListener('error', () => {
if (preview !== image || !dialog.open) return;
status.textContent = '图片加载失败,请关闭后用新标签打开原图。';
});
dialog.append(image);
image.src = link.href;
closeButton.focus({ preventScroll: true });
return true;
}
closeButton.addEventListener('click', close);
dialog.addEventListener('click', event => {
if (event.target === dialog) close();
});
dialog.addEventListener('cancel', event => {
event.preventDefault();
close(); // Esc 也走同一套清理。
});
dialog.addEventListener('close', () => {
if (!dialog.open) restore();
});
document.addEventListener('click', event => {
if (event.defaultPrevented || event.button !== 0 ||
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
if (!(event.target instanceof Element)) return;
const link = event.target.closest('a.demo-lightbox-link[href]');
if (!link || link.hasAttribute('download')) return;
const target = (link.getAttribute('target') || '').toLowerCase();
if (target && target !== '_self') return;
if (open(link)) event.preventDefault();
});
})();
每次打开都创建新的预览图片,旧请求的回调只有在仍对应当前图片时才能更新界面。关闭时移除预览的 src 和节点,避免下次打开先闪出旧图;这不等于承诺所有网络传输都能立即取消。加载失败会显示文字,关闭按钮仍可使用,不会只剩一个永远转动的图标。
原生模态解决了什么
showModal() 将对话框放到浏览器的顶层,并让同一文档中的背景内容进入不可交互状态。可聚焦的关闭按钮提供明确出口,Esc 产生的 cancel 事件也统一执行清理。只给一个全屏 div 加高 z-index,并不会自动获得这些行为。
页面滚动锁仍在示例中显式处理:先保存原值,关闭后原样还原,而不是一律写成 auto。焦点恢复也检查触发链接是否还在文档中,避免对已移除的节点调用阅读流程。打开和恢复焦点都使用 preventScroll,减少焦点操作引起的意外跳动。
Esc 的监听放在这个对话框自身的 cancel 事件上,不需要在整个文档上截获所有 Escape。关闭按钮保留文字和可见的焦点轮廓,方便键盘用户定位出口。点击图片本身不会关闭预览,只有空白背景会关闭;这样保存图片、查看细节时,不容易因为一次普通点击就丢掉当前预览。保留原始链接,也让浏览器的新标签和长按菜单仍有可以访问的地址。
实际检查比行数更重要
用 Tab 走到图片链接,按 Enter 打开,再分别测试关闭按钮、背景点击和 Esc。确认关闭后焦点回到原链接,页面仍停在原阅读位置;随后用 Ctrl 或 Command 点击,检查独立示例是否保留新标签打开。还要临时改错图片地址,确认失败提示与关闭流程照常工作。
最后在窄屏下看大图和关闭按钮是否都留在可视区域。正文的 loading="lazy" 只控制缩略图加载时机,灯箱则在打开时给预览图设置真实 src;两者不需要共享一套懒加载插件。若需要多图切换、缩放手势或图集管理,再按实际需求扩展,不把这一份图片预览示例当成完整图库。
本篇只讨论图片交互。代码工具栏与长行排版可以继续看排版篇,内容在静态站中的检索路径见搜索篇。