重写手机图片预览(不参考旧实现):

- 真凶补刀:lightbox.js 状态模块里一直有 body.style.overflow='hidden'——上次撤销只撤了组件没撤它,撑屏从未消失。现在彻底移除,注释写明绝不可再碰 body inline overflow(会覆盖 overflow-x: clip)
- 滚动封锁改由遮罩自身 @wheel/@touchmove.prevent 拦截:事件不落页面,背景滚不动、滚动位置保留
- 图片尺寸全走百分比(遮罩即视口),不再引入 vw/vh/dvh
- touch-action:none 手势归遮罩:滑动翻页(位移阈值判定)、屏蔽双击缩放/长按菜单/下拉刷新
- 手机断点:箭头贴边缩小、关闭钮/页码走 safe-area、图片全宽无圆角
- Transition 加 :duration 兜底,防 transitionend 不触发时透明遮罩残留吃点击
This commit is contained in:
Sakurasan
2026-09-29 02:27:42 +08:00
parent 84f88cb473
commit 1a43199ca0
2 changed files with 77 additions and 35 deletions
+72 -32
View File
@@ -2,41 +2,70 @@
import { onBeforeUnmount, watch } from 'vue' import { onBeforeUnmount, watch } from 'vue'
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox' import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
// 图片预览层:App.vue 全局挂一次。铺满全屏的暗底 + 居中大图, // 图片预览层(手机重做版):App.vue 全局挂一次。
// 多图时左右箭头 / 方向键切换,角标显示第几张;Esc / 点空白收起。 //
// 手机上出过两次事故,这次的实现原则:
// 1. 不碰 body 的 overflow——之前用 inline overflow 锁背景,把样式表的
// overflow-x: clip 覆盖成 hidden,横向从「裁剪」变「可滚动容器」,
// 整页被拖宽。现在滚动封锁靠遮罩自己的 @wheel/@touchmove.prevent:
// 事件不落到页面里,背景自然滚不动,滚动位置也天然保留。
// 2. 图片尺寸全部用百分比(max-width/height: 100%),不引入 vw/vh/dvh——
// 这些单位在全面屏 URL 栏收展时各有各的坑,遮罩本身就是视口大小。
// 3. 手势全归遮罩:touch-action: none + touchend 位移判滑动翻页,
// 双击缩放/长按菜单/下拉刷新这些浏览器手势都不会来捣乱。
// 4. 桌面能力不变:Esc / 方向键 / 点空白关闭 / 悬停箭头。
function onKey(e) { function onKey(e) {
if (e.key === 'Escape') closeLightbox() if (e.key === 'Escape') closeLightbox()
if (e.key === 'ArrowLeft') stepLightbox(-1) if (e.key === 'ArrowLeft') stepLightbox(-1)
if (e.key === 'ArrowRight') stepLightbox(1) if (e.key === 'ArrowRight') stepLightbox(1)
} }
// 吸顶栏带 backdrop-filter,真机上其合成层会盖在遮罩之上
// (z-index 更高也拦不住)——开预览时整个藏掉
watch( watch(
() => st.open, () => st.open,
(v) => { (v) => {
if (v) { if (v) window.addEventListener('keydown', onKey)
window.addEventListener('keydown', onKey) else window.removeEventListener('keydown', onKey)
// 吸顶栏带 backdrop-filter,真机上其合成层会盖在遮罩之上 document.body.classList.toggle('lb-open', v)
// (遮罩 z-index 更高也拦不住)——开预览时整个藏掉
document.body.classList.add('lb-open')
} else {
window.removeEventListener('keydown', onKey)
document.body.classList.remove('lb-open')
}
} }
) )
onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
document.body.classList.remove('lb-open')
})
// 滑动翻页:横向位移超 44px 且明显大于纵向才算一次滑动
let touchX = 0
let touchY = 0
function onTouchStart(e) {
touchX = e.touches[0].clientX
touchY = e.touches[0].clientY
}
function onTouchEnd(e) {
if (st.list.length < 2) return
const dx = e.changedTouches[0].clientX - touchX
const dy = e.changedTouches[0].clientY - touchY
if (Math.abs(dx) > 44 && Math.abs(dx) > Math.abs(dy) * 1.4) {
stepLightbox(dx < 0 ? 1 : -1)
}
}
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="lb"> <Transition name="lb" :duration="220">
<div v-if="st.open" class="lb-overlay" @click="closeLightbox"> <div
<img v-if="st.open"
class="lb-img" class="lb-overlay"
:src="st.list[st.index]" @click="closeLightbox"
alt="" @touchstart.passive="onTouchStart"
@click.stop @touchend.passive="onTouchEnd"
/> @wheel.prevent
@touchmove.prevent
>
<img class="lb-img" :src="st.list[st.index]" alt="" @click.stop />
<button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button> <button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button>
<button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button> <button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button>
<button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button> <button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
@@ -51,19 +80,23 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 130; z-index: 130;
background: rgba(12, 11, 10, 0.88); background: rgba(12, 11, 10, 0.92);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
/* 遮罩全权处理手势:杜绝双击缩放/长按菜单/下拉刷新来添乱 */
touch-action: none;
} }
/* 百分比尺寸:遮罩即视口,图片永远不会超出屏幕半像素 */
.lb-img { .lb-img {
max-width: min(1200px, 92vw); max-width: 100%;
max-height: 88vh; max-height: 100%;
object-fit: contain; object-fit: contain;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6); box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
user-select: none; user-select: none;
-webkit-user-drag: none;
} }
.lb-nav { .lb-nav {
@@ -130,29 +163,36 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@media (max-width: 640px) { /* 手机:滑动翻页为主,箭头贴边缩小;位置全走安全区 */
@media (pointer: coarse) {
.lb-img { .lb-img {
max-width: 96vw; border-radius: 0;
max-height: 74vh;
} }
.lb-nav { .lb-nav {
width: 38px; width: 40px;
height: 38px; height: 40px;
font-size: 22px; font-size: 22px;
background: rgba(255, 255, 255, 0.12);
} }
.lb-nav.prev { .lb-nav.prev {
left: 10px; left: calc(6px + env(safe-area-inset-left));
} }
.lb-nav.next { .lb-nav.next {
right: 10px; right: calc(6px + env(safe-area-inset-right));
} }
.lb-close { .lb-close {
top: 10px; top: calc(10px + env(safe-area-inset-top));
right: 10px; right: calc(10px + env(safe-area-inset-right));
width: 42px;
height: 42px;
}
.lb-count {
bottom: calc(14px + env(safe-area-inset-bottom));
} }
} }
+5 -3
View File
@@ -3,7 +3,11 @@ import { reactive } from 'vue'
// 全站通用的图片预览(lightbox)。任何地方的配图都可以 // 全站通用的图片预览(lightbox)。任何地方的配图都可以
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是 // openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。 // 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
// 打开时锁 body 滚动(和菜单 sheet 同一套做法)。 //
// 注意:这里绝不能碰 body 的 inline overflow——样式表的 overflow-x: clip
// 会被整个 overflow: hidden 覆盖,横向从「裁剪」变「可滚动容器」,
// 手机上整页能被拖宽(真实事故)。背景滚动由 ImgLightbox 遮罩自己的
// @wheel/@touchmove.prevent 拦截,滚动位置天然保留。
const state = reactive({ const state = reactive({
list: [], list: [],
index: 0, index: 0,
@@ -17,12 +21,10 @@ export function openLightbox(list, index = 0) {
state.list = list state.list = list
state.index = Math.max(0, Math.min(index, list.length - 1)) state.index = Math.max(0, Math.min(index, list.length - 1))
state.open = true state.open = true
document.body.style.overflow = 'hidden'
} }
export function closeLightbox() { export function closeLightbox() {
state.open = false state.open = false
document.body.style.overflow = ''
} }
export function stepLightbox(delta) { export function stepLightbox(delta) {