/*! * 图片灯箱(正文图片点击放大、多图轮播、键盘切换) * Yongquan Yang * * ── 设计原则:没有 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 // 当前展示的 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 做比较, 而且 用绝对地址能避免相对路径在覆盖层里解析错。 */ 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 = '' btnNext = el('button', 'yang-lb-nav yang-lb-next') btnNext.setAttribute('type', 'button') btnNext.setAttribute('aria-label', CFG.next || '下一张') btnNext.innerHTML = '' var btnClose = el('button', 'yang-lb-close') btnClose.setAttribute('type', 'button') btnClose.setAttribute('aria-label', CFG.close || '关闭') btnClose.innerHTML = '' 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 } } })()