feat(lightbox): 正文图片点击在当前页放大、多图轮播、键盘切换

新增主题自带的图片灯箱,替代「点图片跳新标签页」的旧行为,
不再依赖第三方灯箱插件。

主要能力
· 正文里的图片链接改写成「在当前页覆盖展示原图」,不跳转
· 原图优先按附件 ID 取 full size(wp_get_attachment_image_src),
  取不到才退回 href 自己 —— 不在前端从缩略图文件名反推原图地址
· 原图地址写在 href 里而不是 data-full:主题的 CDN 是整页输出缓冲
  改写,只认 src= / href= 与 CSS url(),写 data-* 会绕回源站
· 多图左右箭头轮播(到头循环),键盘 ←↑ / →↓ 切换、Esc 关闭、
  Home / End 跳首尾,点空白关闭,打开时锁滚动并在关闭后归还焦点
· 两套遮罩配色(白色默认 / 深色)与九档不透明度(默认 80%)
· 图片本身始终保持不透明:透明度只作用在遮罩底色上

兼容性处理
· 剥掉第三方灯箱插件留下的触发属性(data-rel / data-imagelightbox /
  data-rl_title 等)。线上实测 100 篇文章 112 张图**全部**带这些属性,
  若只「让给插件」则主题灯箱永不生效;同时关掉开关时一律不动,
  插件可照常工作
· 移除 imgnofollow() 给图片链接加的 target="_blank" 与 rel,
  链接不再承担跳转职责
· 无 JS 时 href 仍在,右键「在新标签页打开图片」照常可用

可读性
· 半透明遮罩下底部说明条自带更实的底(≥0.9),并按「遮罩底下是
  纯黑」的最坏情况验证文字对比度 ≥ 4.5:1
· 行内不透明度样式用 [data-theme] 选择器,避免被样式表默认值压掉
  (裸类名优先级不足,会让深色配色下透明度静默失效)

验证
· 服务端正文改写探针 51 通过 / 0 失败(PHP 8.4 与 8.5)
· 浏览器真实交互探针 200 通过 / 0 失败(两配色 × 80%,含 50%/100%)
· 上述两者的反向验证 21 项注入全部按预期捕获
· 三个改动文件 php -l 通过

Refs #10
This commit is contained in:
2026-10-25 22:05:24 +08:00
parent 203721b1fe
commit 7125fdd3ce
6 changed files with 1367 additions and 1 deletions
+364
View File
@@ -0,0 +1,364 @@
/*!
* 图片灯箱(正文图片点击放大、多图轮播、键盘切换)
* Yongquan Yang <yangyq.net>
*
* ── 设计原则:没有 JS 也要能用 ────────────────────────────────
* 服务端已经给每个图片链接写好了 `href="原图"`,所以**无 JS 时点击
* 仍然是打开原图**(浏览器默认行为)。这个文件只负责把那次跳转
* 改成「在当前页覆盖展示」——任何异常都只是退回默认行为,不会白屏、
* 不会点了没反应。
*
* ── 为什么不用现成的灯箱库 ────────────────────────────────────
* 主题一直坚持零第三方依赖(不需要 CDN、不需要打包、不需要许可证审查)。
* 这里要的功能就三样:覆盖层、左右轮播、键盘响应。原生 API 足够,
* 引一个几十 KB 的库反而多一份被 CDN 挂掉就全站变慢的风险。
*
* ── 键盘约定 ──────────────────────────────────────────────────
* ← → 上一张/下一张(用户明确要求「左右可以查看」)
* ↑ ↓ 同样上一张/下一张(用户明确要求「上下左右键都可以切换」)
* Esc 关闭
* Home / End 首张 / 末张
* 打开时要**记住焦点来源**,关闭后把焦点还给那张图 —— 否则键盘用户
* 关掉之后焦点落在 body 上,得从头 Tab 一遍才能回到原处。
*/
;(function () {
'use strict'
var CFG = window.YANG_LB || {}
/* ── 状态 ───────────────────────────────────────────────── */
var items = [] // 当前文章里所有可点图片:[{ href, alt, el }]
var box = null // 覆盖层根节点
var imgEl = null // 当前展示的 <img>
var capEl = null // 底部说明(alt)
var cntEl = null // 计数「3 / 8」
var btnPrev = null
var btnNext = null
var cur = 0
var lastFocus = null // 打开前的焦点,关闭时归还
var opened = false
/* 锁滚动时记住原来的值,关闭时精确还原
⚠️ 不要写 `document.body.style.overflow = ''` 一刀切:
主题或插件可能本来就在 body 上设了 overflow,清空会把它弄丢。 */
var prevOverflow = null
var prevPaddingRight = null
/* ── 工具 ───────────────────────────────────────────────── */
function el (tag, cls, txt) {
var n = document.createElement(tag)
if (cls) { n.className = cls }
if (txt != null) { n.textContent = txt }
return n
}
/* 相对地址补成绝对地址:灯箱里要用 URL 做比较,
而且 <img src> 用绝对地址能避免相对路径在覆盖层里解析错。 */
function abs (u) {
if (!u) { return '' }
try {
return new URL(u, document.baseURI).href
} catch (e) {
return u
}
}
function fmt (tpl, a, b) {
return String(tpl || '%1$s / %2$s')
.replace('%1$s', a)
.replace('%2$s', b)
}
/* ── 收集可点图片 ───────────────────────────────────────── */
/* 判据与服务端一致:`data-yang-lb="1"`。
不用 `.content img` 一把梭 —— 那会把侧栏卡片、相关推荐里的图
也算进来,轮播顺序会变得莫名其妙(用户点的是正文第 2 张,
结果「下一张」跳到了页面底部的推荐图)。 */
function collect () {
var nodes = document.querySelectorAll('a[data-yang-lb="1"]')
var out = []
for (var i = 0; i < nodes.length; i++) {
var a = nodes[i]
var href = abs(a.getAttribute('href'))
if (!href) { continue }
var im = a.querySelector('img')
out.push({
href: href,
alt: (im && im.getAttribute('alt')) || '',
el: a
})
}
return out
}
/* ── 构建覆盖层(懒创建:只有真的点开过才建 DOM)───────── */
function build () {
if (box) { return }
box = el('div', 'yang-lb')
box.setAttribute('role', 'dialog')
box.setAttribute('aria-modal', 'true')
box.setAttribute('aria-label', '图片查看')
box.setAttribute('tabindex', '-1')
box.setAttribute('data-theme', CFG.theme === 'light' ? 'light' : 'dark')
var stage = el('div', 'yang-lb-stage')
btnPrev = el('button', 'yang-lb-nav yang-lb-prev')
btnPrev.setAttribute('type', 'button')
btnPrev.setAttribute('aria-label', CFG.prev || '上一张')
btnPrev.innerHTML = '<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">'
+ '<path d="M15 4 L7 12 L15 20" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
btnNext = el('button', 'yang-lb-nav yang-lb-next')
btnNext.setAttribute('type', 'button')
btnNext.setAttribute('aria-label', CFG.next || '下一张')
btnNext.innerHTML = '<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">'
+ '<path d="M9 4 L17 12 L9 20" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
var btnClose = el('button', 'yang-lb-close')
btnClose.setAttribute('type', 'button')
btnClose.setAttribute('aria-label', CFG.close || '关闭')
btnClose.innerHTML = '<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">'
+ '<path d="M5 5 L19 19 M19 5 L5 19" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round"/></svg>'
imgEl = el('img', 'yang-lb-img')
imgEl.setAttribute('alt', '')
imgEl.setAttribute('decoding', 'async')
capEl = el('div', 'yang-lb-cap')
cntEl = el('div', 'yang-lb-count')
stage.appendChild(imgEl)
box.appendChild(btnClose)
box.appendChild(btnPrev)
box.appendChild(stage)
box.appendChild(btnNext)
var foot = el('div', 'yang-lb-foot')
foot.appendChild(capEl)
foot.appendChild(cntEl)
box.appendChild(foot)
document.body.appendChild(box)
/* 点空白处关闭:判 `e.target === box` 才算「空白」。
⚠️ 不能用 `box.contains(e.target)` 反向判 —— 那样点图片本身
也会被当成空白(图片是 stage 的子节点,但不是 box 的直接
空白区),一放大地图就关掉了。 */
box.addEventListener('click', function (e) {
if (e.target === box || e.target.classList.contains('yang-lb-stage')) {
hide()
}
})
btnClose.addEventListener('click', function (e) {
e.preventDefault()
hide()
})
btnPrev.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
go(cur - 1)
})
btnNext.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
go(cur + 1)
})
/* 触屏左右滑动 —— 手机上箭头不好按,滑动是本能动作。
只判水平位移,且要大于竖直位移,避免和页面滚动抢手势。 */
var tx = 0
var ty = 0
var tracking = false
box.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { return }
tx = e.touches[0].clientX
ty = e.touches[0].clientY
tracking = true
}, { passive: true })
box.addEventListener('touchend', function (e) {
if (!tracking) { return }
tracking = false
var t = e.changedTouches && e.changedTouches[0]
if (!t) { return }
var dx = t.clientX - tx
var dy = t.clientY - ty
if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.4) {
go(dx < 0 ? cur + 1 : cur - 1)
}
}, { passive: true })
}
/* ── 显示 / 隐藏 ────────────────────────────────────────── */
function lockScroll () {
prevOverflow = document.body.style.overflow
prevPaddingRight = document.body.style.paddingRight
/* 滚动条宽度补偿:隐藏滚动条会让页面横向「跳」一下,
加等宽 padding 就不会。只在真有滚动条时补。 */
var sbw = window.innerWidth - document.documentElement.clientWidth
document.body.style.overflow = 'hidden'
if (sbw > 0) {
var pad = parseInt(window.getComputedStyle(document.body).paddingRight, 10) || 0
document.body.style.paddingRight = (pad + sbw) + 'px'
}
}
function unlockScroll () {
document.body.style.overflow = prevOverflow == null ? '' : prevOverflow
document.body.style.paddingRight = prevPaddingRight == null ? '' : prevPaddingRight
}
function paint () {
var it = items[cur]
if (!it) { return }
imgEl.setAttribute('src', it.href)
imgEl.setAttribute('alt', it.alt || '')
capEl.textContent = it.alt || ''
capEl.style.display = it.alt ? '' : 'none'
cntEl.textContent = fmt(CFG.counter, cur + 1, items.length)
/* 只有一张图时箭头没有意义,藏起来(但要保留占位,
否则图片会突然变宽、视觉上跳一下) */
var multi = items.length > 1
btnPrev.style.visibility = multi ? 'visible' : 'hidden'
btnNext.style.visibility = multi ? 'visible' : 'hidden'
cntEl.style.display = multi ? '' : 'none'
}
function show (i) {
collectInto()
if (!items.length) { return }
build()
cur = Math.max(0, Math.min(i, items.length - 1))
lastFocus = document.activeElement
paint()
box.classList.add('is-open')
opened = true
lockScroll()
/* 焦点移到覆盖层:键盘用户按 Esc 才能被我们接住。
⚠️ 必须 preventScroll —— 有些浏览器 focus() 会把页面滚到元素位置,
覆盖层是 position:fixed,滚了就等于背景跳一下。 */
try {
box.focus({ preventScroll: true })
} catch (e) {
box.focus()
}
}
function hide () {
if (!opened || !box) { return }
opened = false
box.classList.remove('is-open')
unlockScroll()
/* 清掉 src,让大图能被回收(长文章里逐张翻过去会占几百 MB) */
imgEl.removeAttribute('src')
if (lastFocus && lastFocus.focus) {
try {
lastFocus.focus({ preventScroll: true })
} catch (e) {
lastFocus.focus()
}
}
lastFocus = null
}
function go (i) {
if (!items.length) { return }
/* 循环:到最后一张再往后回到第一张。
用户说的是「轮播」,循环是轮播的应有之义。 */
cur = (i + items.length) % items.length
paint()
}
/* 每次打开都重新收集:某些页面会动态插入图片(懒加载插件、
评论区异步加载),缓存一份数组会漏掉新图。 */
function collectInto () {
items = collect()
}
/* ── 事件绑定 ───────────────────────────────────────────── */
/* 委托到 document:正文里的图片可能在 JS 跑完之后才插入
(无限滚动、某些编辑器动态渲染)。绑在具体元素上会漏。 */
document.addEventListener('click', function (e) {
var a = e.target
while (a && a !== document) {
if (a.nodeType === 1 && a.getAttribute && a.getAttribute('data-yang-lb') === '1') {
/* 只接管「普通左键点击」:
Ctrl/Cmd/Shift/中键 都要放行 —— 用户想在新标签页看图
是他的正当选择,拦掉会被骂。 */
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return
}
e.preventDefault()
collectInto()
var idx = 0
for (var i = 0; i < items.length; i++) {
if (items[i].el === a) { idx = i; break }
}
show(idx)
return
}
a = a.parentNode
}
}, false)
document.addEventListener('keydown', function (e) {
if (!opened) { return }
/* 输入框里按方向键不该翻图(虽然覆盖层里没有输入框,
但插件可能往里塞东西,防一手) */
var t = e.target
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return
}
var k = e.key
if (k === 'Escape' || k === 'Esc') {
e.preventDefault()
hide()
return
}
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault()
go(cur - 1)
return
}
if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault()
go(cur + 1)
return
}
if (k === 'Home') {
e.preventDefault()
go(0)
return
}
if (k === 'End') {
e.preventDefault()
go(items.length - 1)
}
}, false)
/* 窗口尺寸变化时不需要重算 —— 图片用 max-width/max-height 自适应,
这里只是把「滚动条宽度补偿」重做一遍更精确,但代价不值。
跳过。 */
/* 暴露一个极小接口,方便主题自己的探针与用户脚本触发 */
window.YangLightbox = {
open: function (i) { show(typeof i === 'number' ? i : 0) },
close: hide,
next: function () { go(cur + 1) },
prev: function () { go(cur - 1) },
count: function () { return collect().length },
isOpen: function () { return opened }
}
})()