Files
wordpress-kratos-yang/assets/js/lightbox.js
T
laoyang 7125fdd3ce 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
2026-10-25 22:05:24 +08:00

364 lines
13 KiB
JavaScript
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.
/*!
* 图片灯箱(正文图片点击放大、多图轮播、键盘切换)
* 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 }
}
})()