1. 播放器歌词效果
This commit is contained in:
@@ -27,27 +27,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 一大五小 -->
|
||||
<div v-else-if="page.type === 'one-large-five-small'"
|
||||
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20">
|
||||
<div v-reveal="{ variant: 'scale', delay: 100 }" class="col-span-2 row-span-2 relative" :class="frameClass">
|
||||
<img :src="page.images[0]" class="preview-img hover:scale-105 transition-transform duration-1000" @click.stop="previewImage(page.images[0])" />
|
||||
<!-- 一大五小:固定 6 槽占位 + 串行填内容(大图 decode 后再挂小图) -->
|
||||
<div
|
||||
v-else-if="page.type === 'one-large-five-small'"
|
||||
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="page.images[0]"
|
||||
class="preview-img olfs-img"
|
||||
decoding="async"
|
||||
@click.stop="previewImage(page.images[0])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="i in [1,2,3,4,5]" :key="i" v-reveal="{ variant: 'up', delay: i * 80 }" class="relative" :class="frameClass">
|
||||
<img :src="page.images[i]" class="preview-img" @click.stop="previewImage(page.images[i])" />
|
||||
<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="page.images[i]"
|
||||
class="preview-img olfs-img"
|
||||
decoding="async"
|
||||
@click.stop="previewImage(page.images[i])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 九宫格:串行挂载,每 300ms 仅 1 格 v-reveal(不预挂九格观察器) -->
|
||||
<!-- 九宫格:串行挂载 + immediate(由父级 activate 触发) -->
|
||||
<div v-else-if="page.type === 'nine-grid'" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
|
||||
<div
|
||||
v-for="(img, imgIdx) in (page.images || [])"
|
||||
:key="imgIdx"
|
||||
class="aspect-square relative nine-grid-slot"
|
||||
:class="{ 'nine-grid-placeholder': imgIdx >= nineShownCount }"
|
||||
:class="{ 'nine-grid-placeholder': imgIdx >= shownCount }"
|
||||
>
|
||||
<div
|
||||
v-if="imgIdx < nineShownCount && nineGridAnimate"
|
||||
v-if="imgIdx < shownCount && nineGridAnimate"
|
||||
v-reveal="getRevealConfig(imgIdx)"
|
||||
class="absolute inset-0"
|
||||
:class="frameClass"
|
||||
@@ -60,7 +95,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="imgIdx < nineShownCount"
|
||||
v-else-if="imgIdx < shownCount"
|
||||
class="absolute inset-0"
|
||||
:class="frameClass"
|
||||
>
|
||||
@@ -75,11 +110,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 瀑布流 -->
|
||||
<div v-else-if="page.type === 'masonry'" class="g-masonry mt-6 px-1">
|
||||
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'up', delay: imgIdx * 50 }"
|
||||
class="g-masonry-item" :class="frameClass">
|
||||
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
|
||||
<!-- 瀑布流:串行挂载 + immediate -->
|
||||
<div v-else-if="page.type === 'masonry'" ref="multiRootRef" class="g-masonry mt-6 px-1">
|
||||
<div
|
||||
v-for="(img, imgIdx) in (page.images || [])"
|
||||
:key="imgIdx"
|
||||
>
|
||||
<div
|
||||
v-if="imgIdx < shownCount"
|
||||
v-reveal="{ variant: 'up', immediate: true }"
|
||||
class="g-masonry-item"
|
||||
:class="frameClass"
|
||||
>
|
||||
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,11 +142,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拍立得拼贴 -->
|
||||
<div v-else-if="page.type === 'polaroid'" class="g-polaroid mt-6">
|
||||
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 70 }"
|
||||
class="g-polaroid-item" :style="{ transform: polaroidTransform(imgIdx) }">
|
||||
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
|
||||
<!-- 拍立得:串行挂载 + immediate -->
|
||||
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
|
||||
<div
|
||||
v-for="(img, imgIdx) in (page.images || [])"
|
||||
:key="imgIdx"
|
||||
>
|
||||
<div
|
||||
v-if="imgIdx < shownCount"
|
||||
v-reveal="{ variant: 'scale', immediate: true }"
|
||||
class="g-polaroid-item"
|
||||
:style="{ transform: polaroidTransform(imgIdx) }"
|
||||
>
|
||||
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,29 +166,66 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { borderClass } from '@/composables/borderStyles'
|
||||
import { createSequentialMount } from '@/composables/useSequentialMount'
|
||||
|
||||
const STEP_MS = 300
|
||||
const SETTLE_MS = 400
|
||||
const NINE_STEP_MS = 300
|
||||
const NINE_SETTLE_MS = 400
|
||||
const MULTI_STEP_MS = 220
|
||||
const MULTI_SETTLE_MS = 300
|
||||
const OLFS_STEP_MS = 300
|
||||
const OLFS_SETTLE_MS = 320
|
||||
const OLFS_DECODE_TIMEOUT_MS = 1200
|
||||
|
||||
/** 瀑布流/拍立得仍自观测;九宫格/一大五小由父级 activate+停滚驱动 */
|
||||
const SEQUENTIAL_SELF_TYPES = new Set(['masonry', 'polaroid'])
|
||||
const HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small'])
|
||||
|
||||
const props = defineProps({
|
||||
page: { type: Object, required: true },
|
||||
/** 九宫格专用:父级在 section 入视口后设为 true 才挂载展示 */
|
||||
/** 九宫格/一大五小:父级停滚后设为 true 才串行挂载 */
|
||||
activate: { type: Boolean, default: false },
|
||||
/** 开启:串行 v-reveal;关闭:预加载后直接展示 */
|
||||
/** 九宫格:开启串行 v-reveal;关闭则一次展示 */
|
||||
nineGridAnimate: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['preview-image', 'ready'])
|
||||
|
||||
const nineShownCount = ref(0)
|
||||
const shownCount = ref(0)
|
||||
const multiRootRef = ref(null)
|
||||
const olfsLargeImgRef = ref(null)
|
||||
let readyEmitted = false
|
||||
let preloadStarted = false
|
||||
let seqToken = 0
|
||||
let multiObserver = null
|
||||
let multiStarted = false
|
||||
let olfsToken = 0
|
||||
|
||||
const seq = createSequentialMount()
|
||||
const frameClass = computed(() => borderClass(props.page.borderStyle))
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/** 关闭动画时提前暖缓存 */
|
||||
const waitImgReady = (img, timeoutMs) => {
|
||||
if (!img) return Promise.resolve()
|
||||
const decode = () => {
|
||||
if (typeof img.decode === 'function') return img.decode().catch(() => {})
|
||||
if (img.complete) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
img.addEventListener('load', resolve, { once: true })
|
||||
img.addEventListener('error', resolve, { once: true })
|
||||
})
|
||||
}
|
||||
return Promise.race([
|
||||
decode(),
|
||||
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 || []).filter(Boolean)
|
||||
@@ -148,69 +238,153 @@ const preloadNineGridImages = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const emitReady = () => {
|
||||
if (readyEmitted) return
|
||||
readyEmitted = true
|
||||
emit('ready')
|
||||
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()
|
||||
}
|
||||
|
||||
/** 每 300ms 只挂载 1 格并触发单格 v-reveal */
|
||||
const runSequentialReveal = async () => {
|
||||
const token = ++seqToken
|
||||
const total = (props.page.images || []).length
|
||||
const showNineInstant = async () => {
|
||||
shownCount.value = (props.page.images || []).length
|
||||
await nextTick()
|
||||
emitReady()
|
||||
}
|
||||
|
||||
/** 一大五小:先大图并等 decode,再慢挂小图 */
|
||||
const runOlfsSequential = async () => {
|
||||
const token = ++olfsToken
|
||||
const total = Math.min(6, (props.page.images || []).length)
|
||||
if (!total) {
|
||||
emitReady()
|
||||
return
|
||||
}
|
||||
|
||||
nineShownCount.value = 0
|
||||
shownCount.value = 0
|
||||
await nextTick()
|
||||
if (token !== olfsToken) return
|
||||
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
if (token !== seqToken) return
|
||||
await sleep(STEP_MS)
|
||||
if (token !== seqToken) return
|
||||
nineShownCount.value = i + 1
|
||||
await sleep(OLFS_STEP_MS)
|
||||
if (token !== olfsToken) return
|
||||
shownCount.value = 1
|
||||
await nextTick()
|
||||
await waitImgReady(olfsLargeImgRef.value, OLFS_DECODE_TIMEOUT_MS)
|
||||
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 !== seqToken) return
|
||||
await sleep(SETTLE_MS)
|
||||
if (token !== seqToken) return
|
||||
if (token !== olfsToken) return
|
||||
await sleep(OLFS_SETTLE_MS)
|
||||
emitReady()
|
||||
}
|
||||
|
||||
const showNineGridInstant = async () => {
|
||||
const runMultiSequential = async () => {
|
||||
const type = props.page?.type
|
||||
if (!SEQUENTIAL_SELF_TYPES.has(type) || multiStarted) return
|
||||
multiStarted = true
|
||||
const total = (props.page.images || []).length
|
||||
nineShownCount.value = total
|
||||
await seq.run({
|
||||
total,
|
||||
stepMs: MULTI_STEP_MS,
|
||||
settleMs: MULTI_SETTLE_MS,
|
||||
setCount: (n) => { shownCount.value = n },
|
||||
})
|
||||
}
|
||||
|
||||
const unbindMultiObserver = () => {
|
||||
multiObserver?.disconnect()
|
||||
multiObserver = null
|
||||
}
|
||||
|
||||
const bindMultiObserver = async () => {
|
||||
unbindMultiObserver()
|
||||
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) return
|
||||
await nextTick()
|
||||
emitReady()
|
||||
const el = multiRootRef.value
|
||||
if (!el || typeof IntersectionObserver === 'undefined') {
|
||||
// 无观察器时直接串行,避免永不出现
|
||||
if (!multiStarted) runMultiSequential()
|
||||
return
|
||||
}
|
||||
// 露出约 30% 再串行,避免进屏前播完
|
||||
multiObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.3) continue
|
||||
multiObserver?.disconnect()
|
||||
multiObserver = null
|
||||
runMultiSequential()
|
||||
break
|
||||
}
|
||||
},
|
||||
{ threshold: [0.3, 0.5, 0.7], rootMargin: '0px' }
|
||||
)
|
||||
multiObserver.observe(el)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.activate && props.page?.type === 'nine-grid',
|
||||
() => props.activate && HOLD_ACTIVATE_TYPES.has(props.page?.type),
|
||||
async (on) => {
|
||||
if (!on || readyEmitted) return
|
||||
if (props.nineGridAnimate) await runSequentialReveal()
|
||||
else await showNineGridInstant()
|
||||
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()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.page?.type, props.nineGridAnimate, props.page?.images],
|
||||
() => [props.page?.type, props.page?.images],
|
||||
async () => {
|
||||
seq.cancel()
|
||||
olfsToken += 1
|
||||
unbindMultiObserver()
|
||||
multiStarted = false
|
||||
shownCount.value = 0
|
||||
readyEmitted = false
|
||||
if (props.page?.type === 'nine-grid') {
|
||||
if (!props.nineGridAnimate) preloadNineGridImages()
|
||||
return
|
||||
}
|
||||
if (props.page?.type === 'one-large-five-small') return
|
||||
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) {
|
||||
await bindMultiObserver()
|
||||
}
|
||||
},
|
||||
{ deep: true, flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.nineGridAnimate,
|
||||
() => {
|
||||
if (!props.nineGridAnimate) preloadNineGridImages()
|
||||
},
|
||||
{ deep: true }
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.nineGridAnimate) preloadNineGridImages()
|
||||
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) bindMultiObserver()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
seqToken += 1
|
||||
seq.cancel()
|
||||
olfsToken += 1
|
||||
unbindMultiObserver()
|
||||
})
|
||||
|
||||
function getRevealConfig(imgIdx) {
|
||||
@@ -218,17 +392,19 @@ function getRevealConfig(imgIdx) {
|
||||
const col = imgIdx % 3
|
||||
|
||||
if (imgIdx === 4) {
|
||||
return { variant: 'scale', delay: 0 }
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +479,22 @@ function polaroidTransform(i) {
|
||||
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%;
|
||||
|
||||
45
vite-tailwindcss/src/composables/useSequentialMount.js
Normal file
45
vite-tailwindcss/src/composables/useSequentialMount.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* 串行挂载:每隔 stepMs 增加 1 个可见项,避免同时挂大量观察器/解码。
|
||||
*/
|
||||
export function createSequentialMount() {
|
||||
let token = 0
|
||||
|
||||
const cancel = () => {
|
||||
token += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ total: number, stepMs?: number, settleMs?: number, setCount: (n: number) => void }} opts
|
||||
* @returns {Promise<{ cancelled: boolean }>}
|
||||
*/
|
||||
const run = async ({ total, stepMs = 220, settleMs = 300, setCount }) => {
|
||||
const t = ++token
|
||||
const n = Math.max(0, Number(total) || 0)
|
||||
setCount(0)
|
||||
await nextTick()
|
||||
if (t !== token) return { cancelled: true }
|
||||
|
||||
if (!n) return { cancelled: false }
|
||||
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
if (t !== token) return { cancelled: true }
|
||||
await sleep(stepMs)
|
||||
if (t !== token) return { cancelled: true }
|
||||
setCount(i + 1)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
if (t !== token) return { cancelled: true }
|
||||
if (settleMs > 0) {
|
||||
await sleep(settleMs)
|
||||
if (t !== token) return { cancelled: true }
|
||||
}
|
||||
return { cancelled: false }
|
||||
}
|
||||
|
||||
return { run, cancel }
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
* v-reveal -> 默认向上淡入
|
||||
* v-reveal="'left'" -> 从左侧滑入
|
||||
* v-reveal="{ variant: 'scale', delay: 200 }"
|
||||
* v-reveal="{ variant: 'up', immediate: true }" -> 不建观察器,下一帧直接入场(串行挂载用)
|
||||
*
|
||||
* 支持的 variant: up | down | left | right | scale | blur
|
||||
*/
|
||||
@@ -18,22 +19,41 @@ const VARIANTS = {
|
||||
}
|
||||
|
||||
function parseBinding(value) {
|
||||
if (!value) return { variant: 'up', delay: 0 }
|
||||
if (typeof value === 'string') return { variant: value, delay: 0 }
|
||||
if (!value) return { variant: 'up', delay: 0, immediate: false }
|
||||
if (typeof value === 'string') return { variant: value, delay: 0, immediate: false }
|
||||
return {
|
||||
variant: value.variant || 'up',
|
||||
delay: value.delay || 0,
|
||||
immediate: !!value.immediate,
|
||||
}
|
||||
}
|
||||
|
||||
const reveal = {
|
||||
mounted(el, binding) {
|
||||
const { variant, delay } = parseBinding(binding.value)
|
||||
// false:跳过入场(同模板不挂观察器)
|
||||
if (binding.value === false) return
|
||||
|
||||
const { variant, delay, immediate } = parseBinding(binding.value)
|
||||
el.classList.add('reveal')
|
||||
el.classList.add(VARIANTS[variant] || 'reveal-up')
|
||||
if (delay) el.style.transitionDelay = `${delay}ms`
|
||||
|
||||
// 已处于视口(如首屏)时立即显示,避免永远不可见
|
||||
// 串行挂载场景:父级已保证在视口内,免 IntersectionObserver
|
||||
if (immediate) {
|
||||
const kick = () => el.classList.add('reveal--in')
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
el.__revealRaf = requestAnimationFrame(() => {
|
||||
el.__revealRaf = requestAnimationFrame(() => {
|
||||
el.__revealRaf = 0
|
||||
kick()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
el.__revealTimer = setTimeout(kick, 16)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
@@ -51,6 +71,14 @@ const reveal = {
|
||||
el.__revealObserver.disconnect()
|
||||
delete el.__revealObserver
|
||||
}
|
||||
if (el.__revealRaf) {
|
||||
cancelAnimationFrame(el.__revealRaf)
|
||||
el.__revealRaf = 0
|
||||
}
|
||||
if (el.__revealTimer) {
|
||||
clearTimeout(el.__revealTimer)
|
||||
el.__revealTimer = 0
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,16 @@
|
||||
:active="effectsActive"
|
||||
/>
|
||||
|
||||
<!-- 顶部滚动进度条 -->
|
||||
<div class="fixed top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
|
||||
<!-- 顶部滚动进度条(transform 驱动,避免 width/left 强制重排) -->
|
||||
<div
|
||||
class="scroll-progress"
|
||||
aria-hidden="true"
|
||||
:style="{ '--scroll-p': String(Math.min(1, Math.max(0, progress))) }"
|
||||
>
|
||||
<div class="scroll-progress-track" />
|
||||
<div class="scroll-progress-fill" />
|
||||
<span class="scroll-progress-xi calligraphy">囍</span>
|
||||
</div>
|
||||
|
||||
<!-- 右上角:自动滚动 -->
|
||||
<div class="fixed top-6 right-5 z-50 flex flex-col items-end gap-4">
|
||||
@@ -110,11 +118,15 @@
|
||||
title="打开播放器"
|
||||
@click="openPlayerPanel"
|
||||
>
|
||||
<span class="vinyl-rim" aria-hidden="true" />
|
||||
<span class="vinyl-grooves" aria-hidden="true" />
|
||||
<span class="vinyl-sheen" aria-hidden="true" />
|
||||
<span class="vinyl-label-ring" aria-hidden="true" />
|
||||
<span
|
||||
class="vinyl-label"
|
||||
:style="audio.currentCover.value ? { backgroundImage: `url(${audio.currentCover.value})` } : undefined"
|
||||
/>
|
||||
<span class="vinyl-spindle" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -298,17 +310,17 @@
|
||||
class="page-section p-6 relative"
|
||||
:class="data.scrollMode === 'snap' ? 'snap-start' : ''"
|
||||
:style="pageHeightStyle(page)"
|
||||
:data-nine-grid="page.type === 'nine-grid' ? String(pIdx) : undefined">
|
||||
:data-gallery-hold="isHoldGalleryType(page.type) ? String(pIdx) : undefined">
|
||||
<div class="absolute inset-0 bg-white/40 -z-10"></div>
|
||||
<div class="absolute top-0 left-0 w-24 h-24 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
|
||||
<div class="absolute bottom-0 right-0 w-24 h-24 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
|
||||
<div class="w-full h-full flex flex-col justify-center py-6 relative z-10">
|
||||
<PhotoGallery
|
||||
:page="page"
|
||||
:activate="page.type === 'nine-grid' ? !!nineGridActivate[pIdx] : false"
|
||||
:activate="isHoldGalleryType(page.type) ? !!galleryActivate[pIdx] : false"
|
||||
:nine-grid-animate="!!data.nineGridAnimate"
|
||||
@preview-image="openImagePreview"
|
||||
@ready="onNineGridReady(pIdx)"
|
||||
@ready="onGalleryReady(pIdx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -732,8 +744,11 @@ const likeBusy = ref(false)
|
||||
let likePulseTimer = null
|
||||
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
|
||||
const albumPreviewImages = ref([])
|
||||
const nineGridActivate = reactive({})
|
||||
const nineGridHold = ref(false)
|
||||
/** 九宫格 / 一大五小:进视口中线后停滚再串行加载 */
|
||||
const HOLD_GALLERY_TYPES = new Set(['nine-grid', 'one-large-five-small'])
|
||||
const isHoldGalleryType = (type) => HOLD_GALLERY_TYPES.has(type)
|
||||
const galleryActivate = reactive({})
|
||||
const galleryHold = ref(false)
|
||||
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
|
||||
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
|
||||
const danmakuColorOptions = DANMAKU_COLORS
|
||||
@@ -773,13 +788,13 @@ const scheduleBlessingAutoClose = () => {
|
||||
|
||||
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
|
||||
let freeScrollLastTs = 0
|
||||
let nineGridObserver = null
|
||||
let nineGridRevealTimer = null
|
||||
const nineGridDone = new Set()
|
||||
let galleryObserver = null
|
||||
let galleryRevealTimer = null
|
||||
const galleryDone = new Set()
|
||||
const BOTTOM_RETURN_DELAY = 30000
|
||||
const FREE_SCROLL_BASE_STEP = 1.2
|
||||
/** ready 后极短缓冲再恢复滚动(串行等待已在 PhotoGallery 内完成) */
|
||||
const NINE_GRID_RESUME_DELAY_MS = 200
|
||||
const GALLERY_RESUME_DELAY_MS = 200
|
||||
|
||||
const freeInterval = computed(() => {
|
||||
const v = Number(data.value.freeScrollInterval)
|
||||
@@ -915,12 +930,25 @@ const effectsActive = computed(() =>
|
||||
|
||||
const closeDrawer = () => { isDrawerOpen.value = false }
|
||||
const pageScrollTop = () => window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
|
||||
const pageScrollMax = () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
|
||||
/** 缓存文档高度,避免每次 scroll 读 scrollHeight 触发强制重排 */
|
||||
let cachedScrollMax = 0
|
||||
let scrollRaf = 0
|
||||
const refreshScrollMax = () => {
|
||||
cachedScrollMax = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
|
||||
return cachedScrollMax
|
||||
}
|
||||
const pageScrollMax = () => (cachedScrollMax > 0 ? cachedScrollMax : refreshScrollMax())
|
||||
|
||||
const handleScroll = () => {
|
||||
const max = pageScrollMax()
|
||||
progress.value = max > 0 ? pageScrollTop() / max : 0
|
||||
if (isAtBottom()) scheduleBottomReturn()
|
||||
else clearBottomReturn()
|
||||
if (scrollRaf) return
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
scrollRaf = 0
|
||||
const max = pageScrollMax()
|
||||
const top = pageScrollTop()
|
||||
progress.value = max > 0 ? top / max : 0
|
||||
if (max - top <= 8) scheduleBottomReturn()
|
||||
else clearBottomReturn()
|
||||
})
|
||||
}
|
||||
|
||||
const handleUserInteraction = () => {
|
||||
@@ -932,7 +960,10 @@ const handleUserInteraction = () => {
|
||||
}
|
||||
|
||||
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
|
||||
const isAtBottom = () => pageScrollMax() - pageScrollTop() <= 8
|
||||
const isAtBottom = () => {
|
||||
const max = pageScrollMax()
|
||||
return max - pageScrollTop() <= 8
|
||||
}
|
||||
|
||||
const clearBottomReturn = () => {
|
||||
clearTimeout(bottomReturnTimer)
|
||||
@@ -987,45 +1018,51 @@ const canAutoAdvance = () =>
|
||||
&& !previewVisible.value
|
||||
&& !showMapOptions.value
|
||||
&& !showOpenInBrowser.value
|
||||
&& !nineGridHold.value
|
||||
&& !galleryHold.value
|
||||
|
||||
const beginNineGridGate = (pIdx) => {
|
||||
if (nineGridDone.has(pIdx)) return
|
||||
nineGridDone.add(pIdx)
|
||||
nineGridHold.value = true
|
||||
nineGridActivate[pIdx] = true
|
||||
const beginGalleryGate = (pIdx) => {
|
||||
if (galleryDone.has(pIdx)) return
|
||||
galleryDone.add(pIdx)
|
||||
galleryHold.value = true
|
||||
galleryActivate[pIdx] = true
|
||||
}
|
||||
|
||||
const onNineGridReady = (pIdx) => {
|
||||
if (!nineGridDone.has(pIdx)) return
|
||||
clearTimeout(nineGridRevealTimer)
|
||||
nineGridRevealTimer = setTimeout(() => {
|
||||
nineGridHold.value = false
|
||||
nineGridRevealTimer = null
|
||||
}, NINE_GRID_RESUME_DELAY_MS)
|
||||
const onGalleryReady = (pIdx) => {
|
||||
if (!galleryDone.has(pIdx)) return
|
||||
refreshScrollMax()
|
||||
clearTimeout(galleryRevealTimer)
|
||||
galleryRevealTimer = setTimeout(() => {
|
||||
galleryHold.value = false
|
||||
galleryRevealTimer = null
|
||||
}, GALLERY_RESUME_DELAY_MS)
|
||||
}
|
||||
|
||||
const bindNineGridObserver = () => {
|
||||
nineGridObserver?.disconnect()
|
||||
nineGridObserver = null
|
||||
const bindGalleryHoldObserver = () => {
|
||||
galleryObserver?.disconnect()
|
||||
galleryObserver = null
|
||||
if (typeof IntersectionObserver === 'undefined') return
|
||||
const nodes = scrollContainerRef.value?.querySelectorAll('[data-nine-grid]')
|
||||
const nodes = scrollContainerRef.value?.querySelectorAll('[data-gallery-hold]')
|
||||
if (!nodes?.length) return
|
||||
// 进入窗口即 activate;期间 nineGridHold 暂停自动滚
|
||||
nineGridObserver = new IntersectionObserver(
|
||||
// 区块抵达「自下而上 75%」线后再停滚+activate(= 距视口顶 25%)
|
||||
galleryObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const vh = window.innerHeight || 1
|
||||
const lineY = vh * (1 - 0.75) // 从下往上 75%
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
const raw = entry.target.getAttribute('data-nine-grid')
|
||||
const rect = entry.boundingClientRect
|
||||
// 区块跨越该水平线
|
||||
if (rect.top > lineY || rect.bottom < lineY) continue
|
||||
const raw = entry.target.getAttribute('data-gallery-hold')
|
||||
if (raw == null) continue
|
||||
const pIdx = Number(raw)
|
||||
if (!Number.isFinite(pIdx) || nineGridDone.has(pIdx)) continue
|
||||
beginNineGridGate(pIdx)
|
||||
if (!Number.isFinite(pIdx) || galleryDone.has(pIdx)) continue
|
||||
beginGalleryGate(pIdx)
|
||||
}
|
||||
},
|
||||
{ threshold: [0, 0.08, 0.2], rootMargin: '120px 0px' }
|
||||
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1], rootMargin: '0px' }
|
||||
)
|
||||
nodes.forEach((el) => nineGridObserver.observe(el))
|
||||
nodes.forEach((el) => galleryObserver.observe(el))
|
||||
}
|
||||
|
||||
const startAutoScroll = () => {
|
||||
@@ -1045,11 +1082,12 @@ const startAutoScroll = () => {
|
||||
const dt = freeScrollLastTs ? Math.min(34, now - freeScrollLastTs) : 16.7
|
||||
freeScrollLastTs = now
|
||||
if (canAutoAdvance()) {
|
||||
if (isAtBottom()) {
|
||||
const top = pageScrollTop()
|
||||
const max = pageScrollMax()
|
||||
if (max - top <= 8) {
|
||||
scheduleBottomReturn()
|
||||
} else {
|
||||
window.scrollTo(0, pageScrollTop() + FREE_SCROLL_BASE_STEP * (dt / freeInterval.value))
|
||||
if (isAtBottom()) scheduleBottomReturn()
|
||||
window.scrollTo(0, top + FREE_SCROLL_BASE_STEP * (dt / freeInterval.value))
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(tickFreeScroll)
|
||||
@@ -1288,18 +1326,23 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
bindNineGridObserver()
|
||||
bindGalleryHoldObserver()
|
||||
refreshScrollMax()
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', refreshScrollMax, { passive: true })
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.documentElement.classList.remove('page-scroll-snap')
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
nineGridObserver?.disconnect()
|
||||
nineGridObserver = null
|
||||
clearTimeout(nineGridRevealTimer)
|
||||
nineGridRevealTimer = null
|
||||
window.removeEventListener('resize', refreshScrollMax)
|
||||
galleryObserver?.disconnect()
|
||||
galleryObserver = null
|
||||
clearTimeout(galleryRevealTimer)
|
||||
galleryRevealTimer = null
|
||||
cancelAnimationFrame(scrollRaf)
|
||||
scrollRaf = 0
|
||||
cancelAnimationFrame(rafId)
|
||||
cancelAnimationFrame(animId)
|
||||
clearInterval(snapTimer)
|
||||
@@ -1316,7 +1359,8 @@ watch(
|
||||
() => data.value.photoPages,
|
||||
async () => {
|
||||
await nextTick()
|
||||
bindNineGridObserver()
|
||||
bindGalleryHoldObserver()
|
||||
refreshScrollMax()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -1324,7 +1368,7 @@ watch(
|
||||
() => data.value.nineGridAnimate,
|
||||
async () => {
|
||||
await nextTick()
|
||||
bindNineGridObserver()
|
||||
bindGalleryHoldObserver()
|
||||
}
|
||||
)
|
||||
watch(freeScrollSignature, restartAutoScroll)
|
||||
@@ -1486,29 +1530,105 @@ watch(
|
||||
.vinyl-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
.vinyl-disc {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(60, 45, 30, 0.35);
|
||||
border: none;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(40, 30, 22, 0.2) 0 14%, transparent 15%),
|
||||
repeating-radial-gradient(circle at center, #2a2118 0 1px, #3a2f24 1px 3px);
|
||||
box-shadow: 0 6px 16px rgba(60, 45, 30, 0.22);
|
||||
radial-gradient(circle at 50% 50%, #1a1410 0 6%, transparent 6.2%),
|
||||
radial-gradient(circle at 32% 28%, rgba(255, 240, 220, 0.14) 0 10%, transparent 28%),
|
||||
repeating-radial-gradient(
|
||||
circle at center,
|
||||
#1e1813 0 0.6px,
|
||||
#2c241c 0.6px 1.4px,
|
||||
#241c16 1.4px 2.2px,
|
||||
#332a21 2.2px 3px
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(90, 70, 50, 0.45),
|
||||
0 0 0 2px rgba(30, 22, 16, 0.55),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 7px 18px rgba(40, 28, 18, 0.32);
|
||||
}
|
||||
.vinyl-disc.spinning { animation: vinyl-spin 4.8s linear infinite; }
|
||||
.vinyl-grooves { position: absolute; inset: 0; opacity: 0.35; pointer-events: none; }
|
||||
.vinyl-rim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1.5px rgba(180, 150, 110, 0.22),
|
||||
inset 0 0 10px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
.vinyl-grooves {
|
||||
position: absolute;
|
||||
inset: 8%;
|
||||
border-radius: 999px;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
background:
|
||||
repeating-radial-gradient(
|
||||
circle at center,
|
||||
transparent 0 1.5px,
|
||||
rgba(255, 245, 230, 0.045) 1.5px 1.9px,
|
||||
transparent 1.9px 3.4px,
|
||||
rgba(0, 0, 0, 0.18) 3.4px 3.8px
|
||||
);
|
||||
mask-image: radial-gradient(circle at center, transparent 0 26%, #000 28% 92%, transparent 94%);
|
||||
-webkit-mask-image: radial-gradient(circle at center, transparent 0 26%, #000 28% 92%, transparent 94%);
|
||||
}
|
||||
.vinyl-sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
125deg,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0) 28%,
|
||||
rgba(255, 255, 255, 0) 58%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
);
|
||||
mix-blend-mode: soft-light;
|
||||
}
|
||||
.vinyl-label-ring {
|
||||
position: absolute;
|
||||
inset: 25%;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(212, 180, 140, 0.35),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.vinyl-label {
|
||||
position: absolute;
|
||||
inset: 28%;
|
||||
border-radius: 999px;
|
||||
background: #c4a484 center/cover no-repeat;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.25);
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.2), transparent 42%),
|
||||
#c4a484 center/cover no-repeat;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.28),
|
||||
0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.vinyl-spindle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin: -2.5px 0 0 -2.5px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle at 35% 30%, #f0e0c8, #8a704f 70%, #5c4a35);
|
||||
box-shadow: 0 0 0 1px rgba(40, 28, 18, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.vinyl-play-fab {
|
||||
position: absolute;
|
||||
@@ -1527,6 +1647,57 @@ watch(
|
||||
z-index: 2;
|
||||
}
|
||||
.vinyl-play-fab .fa-play { margin-left: 1px; }
|
||||
|
||||
.scroll-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
z-index: 55;
|
||||
pointer-events: none;
|
||||
}
|
||||
.scroll-progress-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(168, 140, 107, 0.08),
|
||||
rgba(168, 140, 107, 0.18),
|
||||
rgba(168, 140, 107, 0.08)
|
||||
);
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.scroll-progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #d4b48a 0%, #a88c6b 55%, #8a704f 100%);
|
||||
box-shadow: 0 0 8px rgba(168, 140, 107, 0.45);
|
||||
transform: scaleX(var(--scroll-p, 0));
|
||||
transform-origin: left center;
|
||||
will-change: transform;
|
||||
}
|
||||
.scroll-progress-xi {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: #a88c6b;
|
||||
text-shadow:
|
||||
0 0 6px rgba(253, 251, 247, 0.95),
|
||||
0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
transform: translate3d(calc(var(--scroll-p, 0) * (100vw - 1.1em) + 0.55em - 50%), 0, 0);
|
||||
will-change: transform;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.player-panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
Reference in New Issue
Block a user