灯箱这类交互,可以用浏览器原生能力完成,不必为单一图片预览额外引入组件库。真正需要处理的不只是放大图片,还包括加载失败、键盘关闭、焦点去向,以及关闭后页面能否继续阅读。

灯箱效果示意点击图片试用主题内置灯箱

先分清主题内置功能与独立示例

当前 Waxy 已经内置灯箱:正文图片经构建期处理后,通过带 data-lightbox 的链接打开原生 dialog。脚本调用 showModal(),把焦点放到关闭按钮,记录页面原有的 body.style.overflow,关闭时再恢复滚动状态和触发图片的链接焦点。

上面的本地图片使用的就是这条内置路径。图片文件放在站点 source/media/demo.jpg,构建后对应 /media/demo.jpg。不需要把下面的示例再次加到主题的 custom_js,否则容易产生两套控制器和两层弹窗。

以下 HTML、CSS、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;两者不需要共享一套懒加载插件。若需要多图切换、缩放手势或图集管理,再按实际需求扩展,不把这一份图片预览示例当成完整图库。

本篇只讨论图片交互。代码工具栏与长行排版可以继续看排版篇,内容在静态站中的检索路径见搜索篇。

参考资料