Files
hunli/vite-tailwindcss/src/components/PhotoGallery.vue

1115 lines
34 KiB
Vue
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.
<template>
<div class="gallery">
<!-- 标题区完整模板主题用统一章节头风格包保留经典排版 -->
<SectionHeading
v-if="layoutTheme"
v-reveal="'up'"
:index="pageIndex"
:kicker="page.subtitle"
:title="page.title"
:note="page.desc"
/>
<div v-else v-reveal="'up'" class="text-center mb-5 px-4">
<span class="gallery-title calligraphy text-xl text-[var(--c-primary)] tracking-[0.3em] font-bold block mb-2">{{ page.title }}</span>
<span class="gallery-subtitle text-[9px] text-[var(--c-muted)] first-letter:uppercase lowercase tracking-[0.4em] block mb-4">{{ page.subtitle }}</span>
<span class="gallery-desc text-[12px] text-[var(--c-muted)] font-light leading-relaxed mx-auto max-w-[280px] tracking-widest block whitespace-pre-wrap">{{ page.desc }}</span>
</div>
<!-- 单图默认居中卡幅 / 可配置宽度铺满 -->
<div v-if="page.type === 'single'" v-reveal="{ variant: singleRevealVariant, delay: 100 }"
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
<div :class="singleFrameClass">
<img :src="displayImg1" class="preview-img" @click.stop="previewImage(originalImg1)" />
<span v-if="showPostmark" class="gf-postmark" aria-hidden="true"></span>
</div>
</div>
<!-- 错落双图固定 2 视口 30% / 50% 分步挂载不停滚 -->
<div
v-else-if="page.type === 'overlap'"
ref="multiRootRef"
class="overlap-wrap relative w-full mt-2"
>
<div
class="absolute left-0 top-0 w-[72%] h-[78%] z-10 -rotate-2 overlap-slot"
:class="{ 'overlap-placeholder': shownCount < 1 }"
>
<div
v-if="shownCount >= 1"
v-reveal="{ variant: 'left', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayImg1"
class="preview-img overlap-img"
decoding="async"
@click.stop="previewImage(originalImg1)"
/>
</div>
</div>
<div
class="absolute right-0 bottom-0 w-[56%] h-[62%] z-20 rotate-3 overlap-slot"
:class="{ 'overlap-placeholder': shownCount < 2 }"
>
<div
v-if="shownCount >= 2"
v-reveal="{ variant: 'right', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayImg2"
class="preview-img overlap-img"
decoding="async"
@click.stop="previewImage(originalImg2)"
/>
</div>
</div>
</div>
<!-- 一大五小固定 6 槽占位 + 串行填内容大图就绪后再挂小图 -->
<div
v-else-if="page.type === 'one-large-five-small'"
ref="multiRootRef"
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20"
>
<div
class="col-span-2 row-span-2 relative olfs-slot"
:class="{ 'olfs-placeholder': shownCount < 1 }"
>
<div
v-if="shownCount >= 1"
v-reveal="{ variant: 'up', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
ref="olfsLargeImgRef"
:src="displayAt(0)"
class="preview-img olfs-img"
decoding="async"
@click.stop="previewImage(originalAt(0))"
/>
</div>
</div>
<div
v-for="i in [1, 2, 3, 4, 5]"
:key="i"
class="relative olfs-slot"
:class="{ 'olfs-placeholder': shownCount <= i }"
>
<div
v-if="shownCount > i"
v-reveal="{ variant: 'up', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayAt(i)"
class="preview-img olfs-img"
decoding="async"
@click.stop="previewImage(originalAt(i))"
/>
</div>
</div>
</div>
<!-- 九宫格近视口 IO 串行挂载双端不停滚 -->
<div v-else-if="page.type === 'nine-grid'" ref="multiRootRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
<div
v-for="(_, imgIdx) in (page.images || [])"
:key="imgIdx"
class="aspect-square relative nine-grid-slot"
:class="{ 'nine-grid-placeholder': imgIdx >= shownCount }"
>
<div
v-if="imgIdx < shownCount && nineGridAnimate"
v-reveal="getRevealConfig(imgIdx)"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayAt(imgIdx)"
class="nine-grid-img preview-img"
decoding="async"
@click.stop="previewImage(originalAt(imgIdx))"
/>
</div>
<div
v-else-if="imgIdx < shownCount"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="displayAt(imgIdx)"
class="nine-grid-img preview-img"
loading="eager"
decoding="async"
@click.stop="previewImage(originalAt(imgIdx))"
/>
</div>
</div>
</div>
<!-- 瀑布流固定槽位 + 串行挂载 -->
<div v-else-if="page.type === 'masonry'" ref="multiRootRef" class="g-masonry mt-6 px-1">
<div
v-for="(_, imgIdx) in (page.images || [])"
:key="imgIdx"
class="g-masonry-slot"
:class="{ 'g-masonry-placeholder': imgIdx >= shownCount }"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: 'up', immediate: true }"
class="g-masonry-item"
:class="frameClass"
>
<img :src="displayAt(imgIdx)" class="preview-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
</div>
<!-- 轮播滑动 -->
<div v-else-if="page.type === 'carousel'" class="relative mt-6">
<div ref="trackRef" class="g-carousel-track no-scrollbar" @scroll="onScroll">
<div v-for="(_, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
<img :src="displayAt(imgIdx)" class="preview-img" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
<button class="g-arrow g-prev" @click="goTo(index - 1)" aria-label="上一张"></button>
<button class="g-arrow g-next" @click="goTo(index + 1)" aria-label="下一张"></button>
<div class="g-dots">
<button v-for="(_, imgIdx) in page.images" :key="imgIdx"
class="g-dot" :class="{ active: imgIdx === index }" @click="goTo(imgIdx)"></button>
</div>
</div>
<!-- 拍立得固定槽位 + 串行挂载 -->
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
<div
v-for="(_, imgIdx) in (page.images || [])"
:key="imgIdx"
class="g-polaroid-slot"
:class="{ 'g-polaroid-placeholder': imgIdx >= shownCount }"
:style="imgIdx >= shownCount ? { transform: polaroidTransform(imgIdx) } : undefined"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: 'scale', immediate: true }"
class="g-polaroid-item"
:style="{ transform: polaroidTransform(imgIdx) }"
>
<img :src="displayAt(imgIdx)" class="preview-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
</div>
<!-- 爱心拼接绝对定位心形轮廓 + 串行挂载拼合完成后心跳一次并飘散小心心 -->
<div
v-else-if="page.type === 'heart-collage'"
ref="multiRootRef"
class="g-heart mt-6"
:class="{ 'g-heart-done': heartDone }"
>
<div
v-for="(slot, imgIdx) in heartSlots"
:key="`${page.heartLayout || 'classic'}-${imgIdx}`"
class="g-heart-slot"
:class="{ 'g-heart-placeholder': imgIdx >= shownCount }"
:style="heartSlotStyle(slot)"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: heartReveal(imgIdx), immediate: true }"
class="absolute inset-0 g-heart-cell"
:class="frameClass"
:style="heartCellShape(slot)"
>
<img
:src="displayAt(imgIdx)"
class="preview-img g-heart-img"
decoding="async"
:style="{ objectPosition: heartFocusCssAt(page, imgIdx) }"
@click.stop="previewImage(originalAt(imgIdx))"
/>
</div>
</div>
<template v-if="heartDone">
<span class="g-heart-spark g-heart-spark-1" aria-hidden="true"></span>
<span class="g-heart-spark g-heart-spark-2" aria-hidden="true"></span>
<span class="g-heart-spark g-heart-spark-3" aria-hidden="true"></span>
</template>
</div>
<!-- 三联屏三竖条并排中列上浮错落 -->
<div v-else-if="page.type === 'triptych'" class="g-triptych mt-6">
<div
v-for="(_, imgIdx) in (page.images || []).slice(0, 3)"
:key="imgIdx"
v-reveal="{ variant: imgIdx === 0 ? 'left' : imgIdx === 2 ? 'right' : 'up', delay: imgIdx * 130 }"
class="g-triptych-item"
:class="[frameClass, { 'g-triptych-mid': imgIdx === 1 }]"
>
<img :src="displayAt(imgIdx)" class="preview-img g-cover-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
<!-- 胶片连拍深色胶片条 + 横滑帧 -->
<div v-else-if="page.type === 'filmstrip'" v-reveal="{ variant: 'up', delay: 100 }" class="g-filmstrip mt-6">
<div class="g-filmstrip-track no-scrollbar">
<div
v-for="(_, imgIdx) in (page.images || [])"
:key="imgIdx"
class="g-filmstrip-frame"
:class="frameClass"
>
<img :src="displayAt(imgIdx)" class="preview-img g-cover-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
</div>
<!-- 扇形叠影五张卡入场后从底部枢轴展开成扇 -->
<div v-else-if="page.type === 'fan'" v-reveal="{ variant: 'scale', delay: 120 }" class="g-fan mt-4">
<div
v-for="(_, imgIdx) in (page.images || []).slice(0, 5)"
:key="imgIdx"
class="g-fan-item"
:class="frameClass"
>
<img :src="displayAt(imgIdx)" class="preview-img g-cover-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
<!-- 杂志拼贴横竖交错三行 grid + 串行挂载 -->
<div v-else-if="page.type === 'mosaic'" ref="multiRootRef" class="g-mosaic mt-6">
<div
v-for="(_, imgIdx) in (page.images || []).slice(0, 6)"
:key="imgIdx"
class="g-mosaic-slot"
:class="[`g-mosaic-area-${imgIdx + 1}`, { 'g-mosaic-placeholder': imgIdx >= shownCount }]"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: imgIdx % 2 === 0 ? 'left' : 'right', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img :src="displayAt(imgIdx)" class="preview-img g-cover-img" decoding="async" @click.stop="previewImage(originalAt(imgIdx))" />
</div>
</div>
</div>
<div v-else class="text-center text-[var(--c-muted)] text-[12px] mt-6">未识别的版式{{ page.type }}</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { borderClass, borderStyleMeta } from '@/composables/borderStyles'
import { createSequentialMount } from '@/composables/useSequentialMount'
import { heartSlotsFor, heartSlotStyle, heartFocusCssAt, heartCellShape } from '@/composables/heartCollage'
import SectionHeading from '@/components/themes/SectionHeading.vue'
const NINE_STEP_MS = 300
const NINE_SETTLE_MS = 400
/** 瀑布/拍立得不停滚,步进略拉长减轻同时 decode */
const MULTI_STEP_MS = 360
const MULTI_SETTLE_MS = 320
/** 错落:视口露出比例分步挂载 + 两图最小间隔 */
const OVERLAP_RATIO_FIRST = 0.3
const OVERLAP_RATIO_SECOND = 0.5
const OVERLAP_MIN_GAP_MS = 520
const OLFS_STEP_MS = 300
const OLFS_SETTLE_MS = 320
const OLFS_DECODE_TIMEOUT_ANDROID_MS = 1200
const OLFS_DECODE_TIMEOUT_IOS_MS = 400
/** 仅用于 OLFS 是否跳过强制 decode */
const isIOSClient = (() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
const platform = navigator.platform || ''
if (/iPhone|iPad|iPod/i.test(ua)) return true
if (platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1) return true
return false
})()
/** 双端均不停滚:近视口 IO 串行挂载 */
const SEQUENTIAL_SELF_TYPES = new Set([
'masonry',
'polaroid',
'nine-grid',
'one-large-five-small',
'heart-collage',
'mosaic',
])
const props = defineProps({
page: { type: Object, required: true },
/** 九宫格:开启串行 v-reveal关闭则一次展示 */
nineGridAnimate: { type: Boolean, default: false },
/** 完整模板主题magazine/newspaper/pixel/zen标题区换统一章节头 */
layoutTheme: { type: Boolean, default: false },
/** 章节序号(完整模板的编号章节头用) */
pageIndex: { type: Number, default: 0 },
})
const emit = defineEmits(['preview-image', 'ready'])
const shownCount = ref(0)
const multiRootRef = ref(null)
const olfsLargeImgRef = ref(null)
let readyEmitted = false
let preloadStarted = false
let multiObserver = null
let multiStarted = false
let olfsToken = 0
let overlapToken = 0
let overlapFirstAt = 0
let overlapSecondTimer = null
const seq = createSequentialMount()
const frameClass = computed(() => borderClass(props.page.borderStyle))
/** 展示:缩放图优先;预览:原图优先 */
const pickDisplay = (original, resized) => resized || original || ''
const pickOriginal = (original, resized) => original || resized || ''
const displayImg1 = computed(() => pickDisplay(props.page?.img1, props.page?.img1Resized))
const displayImg2 = computed(() => pickDisplay(props.page?.img2, props.page?.img2Resized))
const originalImg1 = computed(() => pickOriginal(props.page?.img1, props.page?.img1Resized))
const originalImg2 = computed(() => pickOriginal(props.page?.img2, props.page?.img2Resized))
const displayAt = (i) => pickDisplay(props.page?.images?.[i], props.page?.imagesResized?.[i])
const originalAt = (i) => pickOriginal(props.page?.images?.[i], props.page?.imagesResized?.[i])
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const clearOverlapSecondTimer = () => {
clearTimeout(overlapSecondTimer)
overlapSecondTimer = null
}
/** iOS 跳过 img.decode手动滚动时强制 decode 易触发内存压力);仅等 load/complete */
const waitImgReady = (img, timeoutMs) => {
if (!img) return Promise.resolve()
const waitLoad = () => {
if (img.complete) return Promise.resolve()
return new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true })
img.addEventListener('error', resolve, { once: true })
})
}
const ready = isIOSClient
? waitLoad()
: (typeof img.decode === 'function' ? img.decode().catch(() => {}) : waitLoad())
return Promise.race([
ready,
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
])
}
const emitReady = () => {
if (readyEmitted) return
readyEmitted = true
emit('ready')
}
/** 关闭九宫格动画时提前暖缓存(优先展示用缩放图) */
const preloadNineGridImages = () => {
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
const urls = (props.page.images || [])
.map((_, i) => displayAt(i))
.filter(Boolean)
if (!urls.length) return
preloadStarted = true
for (const url of urls) {
const img = new Image()
img.decoding = 'async'
img.src = url
}
}
/** 注:曾尝试给安卓在 onMounted 阶段后台预热所有图,
* 实测反而更卡顿(下载集中在画廊接近视口时)。已移除。
* 若需预热,应只针对单张且错开更长时间间隔,且仅在用户即将到达时触发。 */
const runNineSequential = async () => {
const total = (props.page.images || []).length
const { cancelled } = await seq.run({
total,
stepMs: NINE_STEP_MS,
settleMs: NINE_SETTLE_MS,
setCount: (n) => { shownCount.value = n },
})
if (!cancelled) emitReady()
}
const showNineInstant = async () => {
shownCount.value = (props.page.images || []).length
await nextTick()
emitReady()
}
/** 一大五小:先大图并等就绪,再慢挂小图 */
const runOlfsSequential = async () => {
const token = ++olfsToken
const total = Math.min(6, (props.page.images || []).length)
if (!total) {
emitReady()
return
}
shownCount.value = 0
await nextTick()
if (token !== olfsToken) return
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return
shownCount.value = 1
await nextTick()
const timeout = isIOSClient ? OLFS_DECODE_TIMEOUT_IOS_MS : OLFS_DECODE_TIMEOUT_ANDROID_MS
await waitImgReady(olfsLargeImgRef.value, timeout)
if (token !== olfsToken) return
for (let n = 2; n <= total; n += 1) {
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return
shownCount.value = n
await nextTick()
}
if (token !== olfsToken) return
await sleep(OLFS_SETTLE_MS)
emitReady()
}
const runMultiSequential = async () => {
const total = (props.page.images || []).length
const { cancelled } = await seq.run({
total,
stepMs: MULTI_STEP_MS,
settleMs: MULTI_SETTLE_MS,
setCount: (n) => { shownCount.value = n },
})
if (!cancelled) emitReady()
}
const unbindMultiObserver = () => {
multiObserver?.disconnect()
multiObserver = null
}
const showOverlapSecond = (token) => {
if (token !== overlapToken) return
if (shownCount.value >= 2) return
shownCount.value = 2
clearOverlapSecondTimer()
unbindMultiObserver()
emitReady()
}
const scheduleOverlapSecond = (token) => {
if (token !== overlapToken || shownCount.value >= 2 || overlapSecondTimer) return
const elapsed = overlapFirstAt ? performance.now() - overlapFirstAt : 0
const wait = Math.max(0, OVERLAP_MIN_GAP_MS - elapsed)
overlapSecondTimer = setTimeout(() => showOverlapSecond(token), wait)
}
/** 错落双图:不停滚;露出 30% 挂第一张50% 挂第二张(并保证最小间隔) */
const bindOverlapObserver = async () => {
unbindMultiObserver()
clearOverlapSecondTimer()
const token = ++overlapToken
overlapFirstAt = 0
await nextTick()
const el = multiRootRef.value
if (!el || typeof IntersectionObserver === 'undefined') {
shownCount.value = 2
emitReady()
return
}
multiObserver = new IntersectionObserver(
(entries) => {
if (token !== overlapToken) return
for (const entry of entries) {
const ratio = entry.intersectionRatio
if (ratio >= OVERLAP_RATIO_FIRST && shownCount.value < 1) {
shownCount.value = 1
overlapFirstAt = performance.now()
}
if (ratio >= OVERLAP_RATIO_SECOND && shownCount.value >= 1) {
scheduleOverlapSecond(token)
}
}
},
{ threshold: [0, OVERLAP_RATIO_FIRST, OVERLAP_RATIO_SECOND, 0.75, 1] }
)
multiObserver.observe(el)
}
/** 按 page.type 调度对应的串行挂载函数 */
const runSequentialByType = async () => {
const type = props.page?.type
if (type === 'nine-grid') {
if (props.nineGridAnimate) await runNineSequential()
else await showNineInstant()
return
}
if (type === 'one-large-five-small') {
await runOlfsSequential()
return
}
if (type === 'masonry' || type === 'polaroid' || type === 'heart-collage' || type === 'mosaic') {
await runMultiSequential()
}
}
const startSequentialMount = async () => {
if (multiStarted || readyEmitted) return
multiStarted = true
await runSequentialByType()
}
const bindMultiObserver = async () => {
unbindMultiObserver()
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) return
await nextTick()
const el = multiRootRef.value
if (!el || typeof IntersectionObserver === 'undefined') {
await startSequentialMount()
return
}
// 近视口再串行,避免页面加载即全量 decode不停滚
multiObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
multiObserver?.disconnect()
multiObserver = null
startSequentialMount()
break
}
},
{ threshold: [0, 0.1, 0.2, 0.3, 0.5], rootMargin: '0px 0px -10% 0px' }
)
multiObserver.observe(el)
}
const bindByType = async () => {
const type = props.page?.type
if (type === 'overlap') {
await bindOverlapObserver()
return
}
if (SEQUENTIAL_SELF_TYPES.has(type)) {
await bindMultiObserver()
}
}
watch(
() => [
props.page?.type,
props.page?.images,
props.page?.imagesResized,
props.page?.img1,
props.page?.img2,
props.page?.img1Resized,
props.page?.img2Resized,
],
async () => {
seq.cancel()
olfsToken += 1
overlapToken += 1
clearOverlapSecondTimer()
unbindMultiObserver()
multiStarted = false
shownCount.value = 0
readyEmitted = false
preloadStarted = false
overlapFirstAt = 0
if (props.page?.type === 'nine-grid' && !props.nineGridAnimate) {
preloadNineGridImages()
}
await bindByType()
},
{ deep: true, flush: 'post' }
)
watch(
() => props.nineGridAnimate,
() => {
if (!props.nineGridAnimate) preloadNineGridImages()
}
)
onMounted(() => {
if (!props.nineGridAnimate) preloadNineGridImages()
bindByType()
})
onUnmounted(() => {
seq.cancel()
olfsToken += 1
overlapToken += 1
clearOverlapSecondTimer()
unbindMultiObserver()
})
function getRevealConfig(imgIdx) {
const row = Math.floor(imgIdx / 3)
const col = imgIdx % 3
if (imgIdx === 4) {
return { variant: 'scale', delay: 0, immediate: true }
}
if (col === 1) {
return {
variant: row === 0 ? 'down' : 'up',
delay: 0,
immediate: true,
}
}
return {
variant: col === 0 ? 'left' : 'right',
delay: 0,
immediate: true,
}
}
/** 当前边框元数据(分组/是否异形裁切/推荐宽高比) */
const singleStyleMeta = computed(() => borderStyleMeta(props.page.borderStyle))
/** 明信片·异形/横幅风景分组用飞入入场,其余保持缩放入场 */
const singleRevealVariant = computed(() =>
['postcard', 'scenic'].includes(singleStyleMeta.value?.group) ? 'postcard' : 'scale'
)
/** 邮戳盖章:仅 明信片/邮票 两种边框叠加 */
const showPostmark = computed(() => ['postcard', 'stamp'].includes(props.page.borderStyle))
const singleWrapClass = computed(() => [
props.page.singleWidth === 'full'
? 'max-w-none'
: (singleStyleMeta.value?.group === 'scenic' ? 'max-w-[340px]' : 'max-w-[300px]'),
// mask 裁切类的投影由外层 drop-shadow 提供box-shadow 会被 mask 裁掉)
singleStyleMeta.value?.shaped ? 'gf-shape-shadow' : '',
].filter(Boolean).join(' '))
const singleWrapStyle = computed(() => {
const ratio = singleStyleMeta.value?.ratio || '4 / 5'
if (props.page.singleWidth === 'full') {
const bleed = { width: 'calc(100% + 48px)', marginLeft: '-24px', marginRight: '-24px' }
return props.page.singleKeepRatio ? bleed : { ...bleed, aspectRatio: ratio }
}
return { aspectRatio: ratio }
})
const singleFrameClass = computed(() =>
frameClass.value + (props.page.singleWidth === 'full' && props.page.singleKeepRatio ? ' gf-natural' : '')
)
const index = ref(0)
const trackRef = ref(null)
function goTo(i) {
const len = props.page.images?.length || 0
if (!len) return
index.value = (i + len) % len
const el = trackRef.value
if (el) el.scrollTo({ left: index.value * el.clientWidth, behavior: 'smooth' })
}
function onScroll() {
const el = trackRef.value
if (!el) return
const i = Math.round(el.scrollLeft / el.clientWidth)
if (i !== index.value) index.value = i
}
function previewImage(src) {
const url = typeof src === 'string' ? src.trim() : String(src?.value || src || '').trim()
if (url && url !== '[object Object]') emit('preview-image', url)
}
const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5]
const polaroidShift = [0, 10, -8, 6, -10, 8, 4, -6]
function polaroidTransform(i) {
const a = polaroidAngles[i % polaroidAngles.length]
const s = polaroidShift[i % polaroidShift.length]
return `rotate(${a}deg) translateY(${s}px)`
}
/** 爱心:左右对称入场,中轴与心尖用缩放 */
/** 当前爱心拼接方案的槽位page.heartLayout 可选classic 默认) */
const heartSlots = computed(() => heartSlotsFor(props.page))
function heartReveal(i) {
// 按槽位横向位置决定入场方向:左半从左入、右半从右入、中轴缩放
const slot = heartSlots.value[i]
if (!slot) return 'up'
const cx = slot.l + slot.w / 2
if (cx > 42 && cx < 58) return 'scale'
return cx <= 42 ? 'left' : 'right'
}
/** 爱心拼合完成:触发一次心跳 + 小心心飘散 */
const heartDone = computed(() =>
props.page?.type === 'heart-collage' && shownCount.value >= heartSlots.value.length
)
</script>
<style scoped>
.gallery { width: 100%; font-family: var(--font-sans); }
.preview-img { cursor: zoom-in; }
.gallery-title {
font-family: var(--font-calligraphy);
font-weight: 700;
}
.gallery-desc { font-family: var(--font-kai); }
.nine-grid-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nine-grid-slot {
background: transparent;
contain: layout paint;
}
.nine-grid-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.olfs-slot {
min-width: 0;
min-height: 0;
contain: layout paint;
}
.olfs-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
}
.olfs-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.overlap-wrap {
width: 100%;
max-width: 420px;
margin-inline: auto;
height: clamp(360px, 56vh, 520px);
padding: 12px 16px;
box-sizing: border-box;
}
.overlap-slot {
contain: layout paint;
}
.overlap-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.overlap-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.g-masonry { column-count: 2; column-gap: 10px; }
.g-masonry-slot {
break-inside: avoid;
margin-bottom: 10px;
contain: layout paint;
}
.g-masonry-placeholder {
visibility: hidden;
pointer-events: none;
min-height: 120px;
}
.g-masonry-item { border-radius: 6px; }
.g-carousel-track {
display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
border-radius: 10px; scrollbar-width: none;
}
.g-carousel-slide { flex: 0 0 100%; min-width: 100%; height: 52vh; scroll-snap-align: center; }
@media (min-width: 768px) {
.gallery {
max-width: 400px;
margin-inline: auto;
}
.overlap-wrap {
max-width: 400px;
height: clamp(340px, 48vh, 460px);
}
.g-heart {
max-width: 400px;
}
.g-carousel-slide {
height: min(52vh, 420px);
}
}
.g-carousel-slide img { width: 100%; height: 100%; object-fit: cover; }
.g-arrow {
position: absolute; top: 50%; transform: translateY(-50%);
width: 34px; height: 34px; border-radius: 9999px; border: none;
background: rgba(255,255,255,0.78); color: var(--c-primary); font-size: 20px; line-height: 1;
display: flex; align-items: center; justify-content: center; cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.12); backdrop-filter: blur(6px);
}
.g-prev { left: 8px; }
.g-next { right: 8px; }
.g-dots { display: flex; justify-content: center; gap: 6px; margin-top: 10px; }
.g-dot { width: 7px; height: 7px; border-radius: 9999px; border: none; background: rgba(var(--c-primary-rgb),0.3); padding: 0; cursor: pointer; transition: all 0.3s; }
.g-dot.active { background: var(--c-primary); width: 18px; border-radius: 9999px; }
/* 爱心拼接:绝对定位块状心形 + 淡囍底纹 */
.g-heart {
position: relative;
width: 100%;
max-width: 400px;
margin-inline: auto;
aspect-ratio: 0.8 / 1;
padding: 4px 2px 8px;
box-sizing: border-box;
isolation: isolate;
}
.g-heart::before,
.g-heart::after {
content: '囍';
position: absolute;
z-index: 0;
font-family: var(--font-calligraphy), serif;
font-weight: 700;
color: #c97878;
pointer-events: none;
user-select: none;
line-height: 1;
}
.g-heart::before {
top: 22%;
left: 50%;
transform: translateX(-50%);
font-size: clamp(64px, 24vw, 108px);
opacity: 0.07;
}
.g-heart::after {
bottom: 10%;
left: 50%;
transform: translateX(-50%);
font-size: clamp(36px, 12vw, 56px);
opacity: 0.05;
}
.g-heart-slot {
position: absolute;
z-index: 1;
min-width: 0;
min-height: 0;
contain: layout paint;
}
.g-heart-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.g-heart-cell {
overflow: hidden;
border-radius: 5px;
transition: transform 0.35s ease, box-shadow 0.35s ease;
}
@media (hover: hover) {
.g-heart-cell:hover {
transform: scale(1.03);
z-index: 3;
box-shadow: 0 6px 18px rgba(80, 50, 40, 0.16);
}
}
.g-heart-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 拼合完成整颗心轻跳两下transform 不影响 absolute 子块布局) */
.g-heart-done {
animation: gHeartBeat 1.15s ease-in-out 0.2s 1;
}
@keyframes gHeartBeat {
0%, 100% { transform: scale(1); }
18% { transform: scale(1.035); }
36% { transform: scale(1); }
54% { transform: scale(1.02); }
70% { transform: scale(1); }
}
/* 完成后从心口飘散的小心心 */
.g-heart-spark {
position: absolute;
z-index: 4;
color: #d9868d;
line-height: 1;
opacity: 0;
pointer-events: none;
user-select: none;
animation: gHeartSpark 1.6s ease-out forwards;
}
.g-heart-spark-1 { left: 20%; top: 24%; font-size: 15px; animation-delay: 0.35s; }
.g-heart-spark-2 { right: 17%; top: 20%; font-size: 11px; animation-delay: 0.55s; }
.g-heart-spark-3 { left: 47%; top: 7%; font-size: 13px; animation-delay: 0.75s; }
@keyframes gHeartSpark {
0% { opacity: 0; transform: translateY(8px) scale(0.5); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-36px) scale(1.2) rotate(14deg); }
}
@media (prefers-reduced-motion: reduce) {
.g-heart-done { animation: none; }
.g-heart-spark { display: none; }
}
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
.g-polaroid-slot {
width: 42%;
contain: layout paint;
}
.g-polaroid-placeholder {
visibility: hidden;
pointer-events: none;
min-height: 160px;
}
.g-polaroid-item {
width: 100%; background: #fff; padding: 8px 8px 24px; border-radius: 2px;
box-shadow: 0 6px 16px rgba(0,0,0,0.12); transition: transform 0.4s ease;
}
.g-polaroid-item:hover { z-index: 10; }
.g-polaroid-item img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; display: block; }
/* 通用:新版式内图片铺满裁切 */
.g-cover-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 三联屏:三竖条,中列上浮(用 margin 错位,避免与 v-reveal 的 transform 冲突) */
.g-triptych {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 14px 4px 0;
}
.g-triptych-item {
aspect-ratio: 10 / 17;
min-width: 0;
}
.g-triptych-mid {
margin-top: -14px;
}
/* 胶片连拍:深色胶片条 + 上下齿孔 + 横滑帧 */
.g-filmstrip {
position: relative;
background: #26231f;
border-radius: 10px;
padding: 20px 10px;
box-shadow: 0 14px 30px rgba(30, 24, 18, 0.28);
}
.g-filmstrip::before,
.g-filmstrip::after {
content: '';
position: absolute;
left: 10px;
right: 10px;
height: 8px;
background-image: repeating-linear-gradient(90deg, rgba(253, 251, 247, 0.8) 0 10px, transparent 10px 20px);
border-radius: 1px;
pointer-events: none;
}
.g-filmstrip::before { top: 6px; }
.g-filmstrip::after { bottom: 6px; }
.g-filmstrip-track {
display: flex;
gap: 8px;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.g-filmstrip-frame {
flex: 0 0 76%;
min-width: 76%;
aspect-ratio: 4 / 5;
scroll-snap-align: center;
}
/* 扇形叠影:五张卡绝对定位于底部枢轴,入场后展开成扇 */
.g-fan {
position: relative;
width: 100%;
max-width: 340px;
margin-inline: auto;
aspect-ratio: 1 / 1.04;
}
.g-fan-item {
position: absolute;
left: 50%;
bottom: 3%;
width: 56%;
aspect-ratio: 3 / 4;
transform-origin: 50% 94%;
transform: translateX(-50%) rotate(0deg);
transition: transform 0.9s cubic-bezier(0.34, 1.45, 0.5, 1);
}
/* 入场(容器 reveal--in后按序展开 */
.reveal--in .g-fan-item:nth-child(1) { transform: translateX(-50%) rotate(-26deg); transition-delay: 0.08s; }
.reveal--in .g-fan-item:nth-child(2) { transform: translateX(-50%) rotate(-13deg); transition-delay: 0.16s; }
.reveal--in .g-fan-item:nth-child(3) { transform: translateX(-50%) rotate(0deg); transition-delay: 0.24s; }
.reveal--in .g-fan-item:nth-child(4) { transform: translateX(-50%) rotate(13deg); transition-delay: 0.32s; }
.reveal--in .g-fan-item:nth-child(5) { transform: translateX(-50%) rotate(26deg); transition-delay: 0.4s; }
@media (prefers-reduced-motion: reduce) {
.g-fan-item { transition: none; }
}
/* 杂志拼贴:横竖交错三行 grid */
.g-mosaic {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
grid-template-areas: 'a b b' 'c c d' 'e f f';
gap: 7px;
aspect-ratio: 3 / 3.1;
padding: 0 2px;
}
.g-mosaic-slot {
position: relative;
min-width: 0;
min-height: 0;
contain: layout paint;
}
.g-mosaic-area-1 { grid-area: a; }
.g-mosaic-area-2 { grid-area: b; }
.g-mosaic-area-3 { grid-area: c; }
.g-mosaic-area-4 { grid-area: d; }
.g-mosaic-area-5 { grid-area: e; }
.g-mosaic-area-6 { grid-area: f; }
.g-mosaic-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
</style>