图片预览手机适配 + 正文图统一接入 lightbox
- lightbox:打开锁背景滚动(手机上背景不再跟着滑)、手机滑动左右翻页、 dvh 跟随全面屏可视高度、关闭钮/角标走 safe-area、图片全宽无圆角 - 全局 capture 委托:时间线/详情页 v-html 正文里的图统一可点开预览, capture+stop 压住整卡跳转;thumbifyHtml 把原图地址留在 data-fullsrc, 预览用原图而非 480w 缩略图;链接卡缩略图不劫持(点击仍是打开链接)
This commit is contained in:
+25
-2
@@ -7,9 +7,26 @@ import ImgLightbox from './components/ImgLightbox.vue'
|
|||||||
import YohakuTop from './ui/yohaku/YohakuTop.vue'
|
import YohakuTop from './ui/yohaku/YohakuTop.vue'
|
||||||
import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
|
import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
|
||||||
import { site, setAdminScope } from './site'
|
import { site, setAdminScope } from './site'
|
||||||
|
import { openLightbox } from './lightbox'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 正文图片点击预览(capture 阶段):时间线/详情页里 v-html 的正文图
|
||||||
|
// 都没有自己的点击入口,这里统一接管。capture + stopPropagation 是为了
|
||||||
|
// 压住整卡点击(时间线点图不该跳详情);配图网格有自己的 handler 不在
|
||||||
|
// .prose 内,天然不受影响。lightbox 用 data-fullsrc 原图(thumbify 保留)。
|
||||||
|
function onProseImgClick(e) {
|
||||||
|
const img = e.target
|
||||||
|
if (!img || img.tagName !== 'IMG') return
|
||||||
|
const scope = img.closest('.prose')
|
||||||
|
if (!scope || img.closest('.post-imgs') || img.closest('a')) return
|
||||||
|
const list = [...scope.querySelectorAll('img')].map((i) => i.dataset.fullsrc || i.currentSrc || i.src)
|
||||||
|
const cur = img.dataset.fullsrc || img.currentSrc || img.src
|
||||||
|
const idx = Math.max(0, list.indexOf(cur))
|
||||||
|
e.stopPropagation()
|
||||||
|
openLightbox(list, idx)
|
||||||
|
}
|
||||||
|
|
||||||
// 正文代码块复制按钮(事件委托:复制按钮是 enhanceProse 注入的静态 HTML)
|
// 正文代码块复制按钮(事件委托:复制按钮是 enhanceProse 注入的静态 HTML)
|
||||||
function onCopyClick(e) {
|
function onCopyClick(e) {
|
||||||
const btn = e.target.closest?.('.pre-copy')
|
const btn = e.target.closest?.('.pre-copy')
|
||||||
@@ -31,8 +48,14 @@ function onCopyClick(e) {
|
|||||||
setTimeout(() => (btn.textContent = '复制'), 1600)
|
setTimeout(() => (btn.textContent = '复制'), 1600)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onMounted(() => document.addEventListener('click', onCopyClick))
|
onMounted(() => {
|
||||||
onBeforeUnmount(() => document.removeEventListener('click', onCopyClick))
|
document.addEventListener('click', onCopyClick)
|
||||||
|
document.addEventListener('click', onProseImgClick, true)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onCopyClick)
|
||||||
|
document.removeEventListener('click', onProseImgClick, true)
|
||||||
|
})
|
||||||
|
|
||||||
const isAdmin = computed(() => route.path.startsWith('/admin'))
|
const isAdmin = computed(() => route.path.startsWith('/admin'))
|
||||||
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
|
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
|
||||||
|
|||||||
@@ -3,27 +3,54 @@ 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 / 点空白收起。
|
// 多图时左右箭头 / 方向键 / 手机滑动切换,角标显示第几张;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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开时锁住背景滚动(手机上尤其明显:否则手指滑动会带着页面一起动)
|
||||||
|
let prevOverflow = ''
|
||||||
watch(
|
watch(
|
||||||
() => st.open,
|
() => st.open,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) window.addEventListener('keydown', onKey)
|
if (v) {
|
||||||
else window.removeEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
|
prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手机滑动翻页:横向位移超 44px 且大于纵向位移才算一次滑动
|
||||||
|
let touchX = 0
|
||||||
|
let touchY = 0
|
||||||
|
function onTouchStart(e) {
|
||||||
|
touchX = e.touches[0].clientX
|
||||||
|
touchY = e.touches[0].clientY
|
||||||
|
}
|
||||||
|
function onTouchEnd(e) {
|
||||||
|
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 && st.list.length > 1) {
|
||||||
|
stepLightbox(dx < 0 ? 1 : -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="lb">
|
<Transition name="lb">
|
||||||
<div v-if="st.open" class="lb-overlay" @click="closeLightbox">
|
<div v-if="st.open" class="lb-overlay" @click="closeLightbox" @touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd">
|
||||||
<img
|
<img
|
||||||
class="lb-img"
|
class="lb-img"
|
||||||
:src="st.list[st.index]"
|
:src="st.list[st.index]"
|
||||||
@@ -124,28 +151,43 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.lb-overlay {
|
||||||
|
/* 全面屏 URL 栏收展时 100vh 会偏大,dvh 跟随可视高度 */
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.lb-img {
|
.lb-img {
|
||||||
max-width: 96vw;
|
max-width: 100vw;
|
||||||
max-height: 74vh;
|
max-height: 74dvh;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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(8px + env(safe-area-inset-left));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lb-nav.next {
|
.lb-nav.next {
|
||||||
right: 10px;
|
right: calc(8px + 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));
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,13 @@ export function applyImageRatio(html) {
|
|||||||
export function thumbifyHtml(html, w = 480) {
|
export function thumbifyHtml(html, w = 480) {
|
||||||
if (!html) return html
|
if (!html) return html
|
||||||
return html.replace(/<img\b[^>]*?>/gi, (tag) => {
|
return html.replace(/<img\b[^>]*?>/gi, (tag) => {
|
||||||
let out = tag.replace(/\bsrc="([^"]*)"/i, (m, src) => 'src="' + thumbURL(src, w) + '"')
|
let out = tag
|
||||||
|
// 原图地址留在 data-fullsrc:lightbox 打开时用原图,不用 480w 缩略图
|
||||||
|
out = out.replace(/\bsrc="([^"]*)"/i, (m, src) => {
|
||||||
|
const thumb = thumbURL(src, w)
|
||||||
|
if (thumb !== src) out = out.replace(/^(<img)/i, '$1 data-fullsrc="' + src + '"')
|
||||||
|
return 'src="' + thumb + '"'
|
||||||
|
})
|
||||||
if (!/\bloading=/i.test(out)) out = out.replace(/^<img/i, '<img loading="lazy"')
|
if (!/\bloading=/i.test(out)) out = out.replace(/^<img/i, '<img loading="lazy"')
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user