2026-10-25 22:05:24 +08:00
|
|
|
|
/*!
|
|
|
|
|
|
* 图片灯箱(正文图片点击放大、多图轮播、键盘切换)
|
|
|
|
|
|
* 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 // 覆盖层根节点
|
2026-10-25 23:03:13 +08:00
|
|
|
|
var imgEl = null // 当前展示的 <img>(原图)
|
|
|
|
|
|
var phEl = null // 占位 <img>(正文里已缓存的那张缩略图)
|
|
|
|
|
|
var spinEl = null // 加载指示
|
2026-10-25 22:05:24 +08:00
|
|
|
|
var capEl = null // 底部说明(alt)
|
|
|
|
|
|
var cntEl = null // 计数「3 / 8」
|
|
|
|
|
|
var btnPrev = null
|
|
|
|
|
|
var btnNext = null
|
|
|
|
|
|
var cur = 0
|
|
|
|
|
|
var lastFocus = null // 打开前的焦点,关闭时归还
|
|
|
|
|
|
var opened = false
|
2026-10-25 23:03:13 +08:00
|
|
|
|
/* 当前这一张的加载状态。异步回调里必须比对 token,
|
|
|
|
|
|
否则「点 A → 还在下载就翻到 B」时,A 的 onload 回来
|
|
|
|
|
|
会把 B 的界面标记成已就绪。 */
|
|
|
|
|
|
var loadToken = 0
|
|
|
|
|
|
var pendingHref = '' // 正在等的那张原图地址(用于命中缓存判断)
|
2026-10-25 22:05:24 +08:00
|
|
|
|
|
|
|
|
|
|
/* 锁滚动时记住原来的值,关闭时精确还原
|
|
|
|
|
|
⚠️ 不要写 `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')
|
2026-10-25 23:03:13 +08:00
|
|
|
|
/* 动手前先把缩略图地址记下来 —— 这是「占位层」唯一的来源。
|
|
|
|
|
|
⚠️ 必须用 `currentSrc` 优先:img 可能带 srcset,
|
|
|
|
|
|
浏览器实际显示的是 currentSrc 挑出来的那张,
|
|
|
|
|
|
它才是**已经在内存里解码过**的那一份。
|
|
|
|
|
|
用 getAttribute('src') 会拿到一个可能没下载过的候选,
|
|
|
|
|
|
点了之后还要再下一次,占位就白做了。 */
|
|
|
|
|
|
var thumb = ''
|
|
|
|
|
|
if (im) {
|
|
|
|
|
|
thumb = im.currentSrc || im.src || im.getAttribute('src') || ''
|
|
|
|
|
|
}
|
2026-10-25 22:05:24 +08:00
|
|
|
|
out.push({
|
|
|
|
|
|
href: href,
|
|
|
|
|
|
alt: (im && im.getAttribute('alt')) || '',
|
2026-10-25 23:03:13 +08:00
|
|
|
|
el: a,
|
|
|
|
|
|
thumb: thumb ? abs(thumb) : ''
|
2026-10-25 22:05:24 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
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>'
|
|
|
|
|
|
|
2026-10-25 23:03:13 +08:00
|
|
|
|
/* 两层结构(原因见 lightbox.css 里 .yang-lb-ph 的注释):
|
|
|
|
|
|
垫底 phEl —— 正文里那张已缓存的缩略图,点开瞬间就有像素
|
|
|
|
|
|
上浮 imgEl —— 原图,解码完成后淡入覆盖
|
|
|
|
|
|
转圈 spinEl —— 慢网下唯一能表达「还在来」的东西
|
|
|
|
|
|
三者叠在 stage 的同一个 grid 格子里,居中由 CSS 负责。 */
|
|
|
|
|
|
phEl = el('img', 'yang-lb-ph')
|
|
|
|
|
|
phEl.setAttribute('alt', '')
|
|
|
|
|
|
phEl.setAttribute('aria-hidden', 'true')
|
|
|
|
|
|
/* 占位图是**装饰**:屏幕阅读器不该读它,
|
|
|
|
|
|
也不该因为它参与一次图片语义(原图已经有一个了)。 */
|
|
|
|
|
|
|
2026-10-25 22:05:24 +08:00
|
|
|
|
imgEl = el('img', 'yang-lb-img')
|
|
|
|
|
|
imgEl.setAttribute('alt', '')
|
|
|
|
|
|
imgEl.setAttribute('decoding', 'async')
|
2026-10-25 23:03:13 +08:00
|
|
|
|
/* fetchpriority 只对「还没开始下载」的图有意义;
|
|
|
|
|
|
这里原图是点开才下的,正是它的用武之地 —— 让浏览器
|
|
|
|
|
|
不要把它排在页面其它资源后面。 */
|
|
|
|
|
|
imgEl.setAttribute('fetchpriority', 'high')
|
|
|
|
|
|
|
|
|
|
|
|
spinEl = el('div', 'yang-lb-spin')
|
|
|
|
|
|
spinEl.setAttribute('aria-hidden', 'true')
|
2026-10-25 22:05:24 +08:00
|
|
|
|
|
|
|
|
|
|
capEl = el('div', 'yang-lb-cap')
|
|
|
|
|
|
cntEl = el('div', 'yang-lb-count')
|
|
|
|
|
|
|
2026-10-25 23:03:13 +08:00
|
|
|
|
stage.appendChild(phEl)
|
2026-10-25 22:05:24 +08:00
|
|
|
|
stage.appendChild(imgEl)
|
2026-10-25 23:03:13 +08:00
|
|
|
|
stage.appendChild(spinEl)
|
2026-10-25 22:05:24 +08:00
|
|
|
|
|
|
|
|
|
|
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 }
|
2026-10-25 23:03:13 +08:00
|
|
|
|
|
|
|
|
|
|
var myToken = ++loadToken
|
|
|
|
|
|
|
|
|
|
|
|
/* ① 占位层:先用正文里那张已解码的缩略图顶上。
|
|
|
|
|
|
⚠️ 只有「缩略图与原图不是同一个地址」时才启用。
|
|
|
|
|
|
本站在多数情况下 href 与 img.src 完全相同(同一文件),
|
|
|
|
|
|
这时占位图等于原图 —— 直接用反而更快(已在缓存里),
|
|
|
|
|
|
不需要等原图第二次请求。 */
|
|
|
|
|
|
phEl.classList.remove('is-fade')
|
|
|
|
|
|
if (it.thumb && it.thumb !== it.href) {
|
|
|
|
|
|
phEl.setAttribute('src', it.thumb)
|
|
|
|
|
|
phEl.classList.add('is-on')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
/* 没有独立缩略图:不改 src(保留上一次的会显示错的图),
|
|
|
|
|
|
直接藏起来,让原图自己承担首帧。 */
|
|
|
|
|
|
phEl.removeAttribute('src')
|
|
|
|
|
|
phEl.classList.remove('is-on')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ② 原图层:先复位成「未就绪」。
|
|
|
|
|
|
复位必须**在同一次同步任务里**完成,否则会闪一下上一张的图。 */
|
|
|
|
|
|
imgEl.classList.remove('is-ready')
|
2026-10-25 22:05:24 +08:00
|
|
|
|
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'
|
2026-10-25 23:03:13 +08:00
|
|
|
|
|
|
|
|
|
|
/* ③ 原图:命中缓存就立刻上屏,否则转圈等加载
|
|
|
|
|
|
🔴 `pendingHref` 是给「命中缓存」判据用的。
|
|
|
|
|
|
浏览器对**已缓存**的图片,设置 src 后 complete 立刻为 true
|
|
|
|
|
|
且不会触发 load 事件 —— 只挂 onload 会永远等不到,
|
|
|
|
|
|
图片永远 opacity:0(用户会以为点了没反应)。
|
|
|
|
|
|
所以这里必须同步查一次 complete。 */
|
|
|
|
|
|
pendingHref = it.href
|
|
|
|
|
|
box.classList.add('is-loading')
|
|
|
|
|
|
|
|
|
|
|
|
imgEl.onload = function () {
|
|
|
|
|
|
if (myToken !== loadToken) { return } // 已经翻到别的图了
|
|
|
|
|
|
onImgReady()
|
|
|
|
|
|
}
|
|
|
|
|
|
imgEl.onerror = function () {
|
|
|
|
|
|
if (myToken !== loadToken) { return }
|
|
|
|
|
|
/* 原图挂了(CDN 404、被防盗链拦、离线):
|
|
|
|
|
|
别把界面卡在转圈上。把占位图留在屏幕上 ——
|
|
|
|
|
|
它至少是这张图的真实内容,只是糊。 */
|
|
|
|
|
|
box.classList.remove('is-loading')
|
|
|
|
|
|
if (phEl.classList.contains('is-on')) {
|
|
|
|
|
|
phEl.classList.remove('is-fade')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
/* 连占位图都没有:只能显示 alt 文本,至少让人知道发生了什么 */
|
|
|
|
|
|
imgEl.classList.add('is-ready')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
imgEl.setAttribute('src', it.href)
|
|
|
|
|
|
|
|
|
|
|
|
/* 同步检查:内存缓存 / 已下载完的图走这条路,不等事件 */
|
|
|
|
|
|
if (imgEl.complete && imgEl.naturalWidth > 0) {
|
|
|
|
|
|
onImgReady()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 原图可以显示了:淡入覆盖占位图、停掉转圈 */
|
|
|
|
|
|
function onImgReady () {
|
|
|
|
|
|
box.classList.remove('is-loading')
|
|
|
|
|
|
imgEl.classList.add('is-ready')
|
|
|
|
|
|
/* 占位图淡出后要摘掉 src,否则一张大缩略图会一直占着内存。
|
|
|
|
|
|
等过渡跑完再摘,避免淡出过程中被抽走而闪黑。 */
|
|
|
|
|
|
if (phEl.classList.contains('is-on')) {
|
|
|
|
|
|
phEl.classList.add('is-fade')
|
|
|
|
|
|
var phToken = loadToken
|
|
|
|
|
|
setTimeout(function () {
|
|
|
|
|
|
if (phToken !== loadToken) { return } // 期间又翻页了,交给新一轮管
|
|
|
|
|
|
phEl.classList.remove('is-on', 'is-fade')
|
|
|
|
|
|
phEl.removeAttribute('src')
|
|
|
|
|
|
}, 240)
|
|
|
|
|
|
}
|
2026-10-25 22:05:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-25 23:03:13 +08:00
|
|
|
|
box.classList.remove('is-open', 'is-loading')
|
2026-10-25 22:05:24 +08:00
|
|
|
|
unlockScroll()
|
2026-10-25 23:03:13 +08:00
|
|
|
|
/* 清掉 src,让大图能被回收(长文章里逐张翻过去会占几百 MB)
|
|
|
|
|
|
⚠️ 必须同时把 onload/onerror 摘掉:清 src 在老浏览器上会触发
|
|
|
|
|
|
error 事件,回调里又去动刚刚复位好的界面,表现为
|
|
|
|
|
|
「关闭后立刻又自己打开」或转圈残留。 */
|
|
|
|
|
|
loadToken++
|
|
|
|
|
|
imgEl.onload = null
|
|
|
|
|
|
imgEl.onerror = null
|
2026-10-25 22:05:24 +08:00
|
|
|
|
imgEl.removeAttribute('src')
|
2026-10-25 23:03:13 +08:00
|
|
|
|
imgEl.classList.remove('is-ready')
|
|
|
|
|
|
phEl.classList.remove('is-on', 'is-fade')
|
|
|
|
|
|
phEl.removeAttribute('src')
|
|
|
|
|
|
pendingHref = ''
|
2026-10-25 22:05:24 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-25 23:03:13 +08:00
|
|
|
|
/* ── 预取:让「点开」这个动作不必等下载 ─────────────────────
|
|
|
|
|
|
*
|
|
|
|
|
|
* 实测(_v40_slow.py,自建慢速 HTTP 服务,线上原图最大 3.2 MB):
|
|
|
|
|
|
*
|
|
|
|
|
|
* 带宽 3.2 Mbps:遮罩 31ms 可见,原图 6 秒后仍无像素
|
|
|
|
|
|
* 带宽 1 Mbps :同样 31ms / 6 秒无像素
|
|
|
|
|
|
*
|
|
|
|
|
|
* 光靠「缩略图占位」只能**掩盖**等待,不能**消除**等待。
|
|
|
|
|
|
* 真正把延迟干掉的办法是提前下载:用户还在读正文时,
|
|
|
|
|
|
* 原图就已经在路上,点开那一刻直接从缓存出图(实测 2.4ms)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 什么时候开始预取 ────────────────────────────────────
|
|
|
|
|
|
* 必须等页面自己先加载完并且空闲下来,否则会跟正文图片、
|
|
|
|
|
|
* 字体、CSS 抢带宽 —— 那反而让**页面本身**变慢,得不偿失。
|
|
|
|
|
|
* 所以用 requestIdleCallback(不支持就退回 load + setTimeout)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 预取到什么程度 ──────────────────────────────────────
|
|
|
|
|
|
* 只预取「已经离视口不远、用户可能马上点」的图:
|
|
|
|
|
|
* · 首屏就能看到的若干张 —— 一定取
|
|
|
|
|
|
* · 视口下方 ~1.5 屏内的 —— 滚到就取
|
|
|
|
|
|
* 远处的图不预取:一篇文章 30 张大图全预取,等于把流量翻倍,
|
|
|
|
|
|
* 用户的流量是要花钱的。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 怎么不抢带宽 ────────────────────────────────────────
|
|
|
|
|
|
* 一次只取一张,上一张 load/error 了再取下一张。
|
|
|
|
|
|
* 且用 `new Image()` 而不是塞进 DOM:不参与布局,不会引发布局抖动。
|
|
|
|
|
|
*/
|
|
|
|
|
|
var prefetched = {}
|
|
|
|
|
|
var prefetchQueue = []
|
|
|
|
|
|
var prefetching = false
|
|
|
|
|
|
|
|
|
|
|
|
function canPrefetch () {
|
|
|
|
|
|
/* 尊重用户的省流量设置。开着省流量时预取是**反用户意愿**的:
|
|
|
|
|
|
他明确表示不想多花流量。 */
|
|
|
|
|
|
var c = navigator.connection
|
|
|
|
|
|
if (c && (c.saveData || c.effectiveType === 'slow-2g')) { return false }
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function enqueue (href) {
|
|
|
|
|
|
if (!href || prefetched[href]) { return }
|
|
|
|
|
|
prefetched[href] = 1
|
|
|
|
|
|
prefetchQueue.push(href)
|
|
|
|
|
|
drain()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function drain () {
|
|
|
|
|
|
if (prefetching || !prefetchQueue.length) { return }
|
|
|
|
|
|
prefetching = true
|
|
|
|
|
|
var href = prefetchQueue.shift()
|
|
|
|
|
|
var im = new Image()
|
|
|
|
|
|
var done = function () {
|
|
|
|
|
|
prefetching = false
|
|
|
|
|
|
/* 一张接一张:串行,绝不并发抢带宽 */
|
|
|
|
|
|
drain()
|
|
|
|
|
|
}
|
|
|
|
|
|
im.onload = done
|
|
|
|
|
|
im.onerror = done
|
|
|
|
|
|
im.src = href
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function prefetchNearby () {
|
|
|
|
|
|
if (!canPrefetch()) { return }
|
|
|
|
|
|
var all = collect()
|
|
|
|
|
|
if (!all.length) { return }
|
|
|
|
|
|
|
|
|
|
|
|
/* 用 getBoundingClientRect 判「离视口近不近」。
|
|
|
|
|
|
⚠️ 不能只看 offsetTop —— 本站正文外层有定位容器,
|
|
|
|
|
|
offsetTop 相对的是最近的定位祖先,不是文档顶。 */
|
|
|
|
|
|
var vh = window.innerHeight || 800
|
|
|
|
|
|
var reach = vh * 2.5 // 视口上下各 1.5 屏左右
|
|
|
|
|
|
for (var i = 0; i < all.length; i++) {
|
|
|
|
|
|
var r = all[i].el.getBoundingClientRect()
|
|
|
|
|
|
if (r.bottom > -reach && r.top < vh + reach) {
|
|
|
|
|
|
enqueue(all[i].href)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startPrefetch () {
|
|
|
|
|
|
/* 页面都加载完了再动手,别跟首屏资源抢 */
|
|
|
|
|
|
var run = function () { prefetchNearby() }
|
|
|
|
|
|
if (window.requestIdleCallback) {
|
|
|
|
|
|
requestIdleCallback(run, { timeout: 3000 })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTimeout(run, 1200)
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 滚动时补取:用户往下滚,新的图进入「可能马上点」的范围 */
|
|
|
|
|
|
var tid = null
|
|
|
|
|
|
window.addEventListener('scroll', function () {
|
|
|
|
|
|
if (tid) { return }
|
|
|
|
|
|
tid = setTimeout(function () {
|
|
|
|
|
|
tid = null
|
|
|
|
|
|
prefetchNearby()
|
|
|
|
|
|
}, 300)
|
|
|
|
|
|
}, { passive: true })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (document.readyState === 'complete') {
|
|
|
|
|
|
startPrefetch()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
window.addEventListener('load', startPrefetch)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-25 22:05:24 +08:00
|
|
|
|
/* ── 事件绑定 ───────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
/* 委托到 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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|