feat(幕布): GVSDS 改为按字形笔画自动绘制 + 一划到底
- 幕布字改为 SVG:每个字母一个 <text>,用 stroke-dasharray/dashoffset 沿 --font-sans 字形轮廓同时起笔,画完补实心 - 轮廓长度用 getComputedTextLength() * 4.2 估算(getTotalLength 不作用于 <text>),viewBox 按实测 bbox 设 - 幕布露出 60% 时起笔,只画一次;prefers-reduced-motion 下直接显示 - 距底不足 0.6 屏时,下一次向下滑动(滚轮/触屏)由 Lenis 平滑滑到底 - App.vue 用 provide 下发 Lenis 实例,避免与 window.scrollTo 抢滚动
This commit is contained in:
1 parent
ec092d648e
commit
b8cc5ab483
2 files changed
+197
-20
No files matched your search
+10
-6
@@ -4,12 +4,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
// 根组件:仅承载路由出口,并在此挂载全局平滑惯性滚动
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { onMounted, onBeforeUnmount, watch, provide, shallowRef } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Lenis from 'lenis'
|
||||
|
||||
// Lenis 平滑惯性滚动实例(接管滚轮,手势结束后继续滑行一段距离再停)
|
||||
let lenis: Lenis | null = null
|
||||
const lenisRef = shallowRef<Lenis | null>(null)
|
||||
|
||||
/** 实例下发到各层组件:SiteLayout 的幕布要"一划到底",得驱动 Lenis,
|
||||
* 而不是用 window.scrollTo 跟它的 rAF 抢滚动位置(两边互相覆盖会抖) */
|
||||
provide('gvsds-lenis', lenisRef)
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -18,7 +22,7 @@ onMounted(() => {
|
||||
// autoRaf 由库内部自驱动 requestAnimationFrame;respectReducedMotion 默认开启,
|
||||
// 系统开启"减弱动态效果"时自动退回原生滚动
|
||||
// allowNestedScroll:弹窗/下拉/多行输入等自带滚动容器的区域交还原生滚动,避免被接管
|
||||
lenis = new Lenis({
|
||||
lenisRef.value = new Lenis({
|
||||
lerp: 0.08,
|
||||
wheelMultiplier: 1.05,
|
||||
autoRaf: true,
|
||||
@@ -31,12 +35,12 @@ onMounted(() => {
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
lenis?.scrollTo(0, { immediate: true, force: true })
|
||||
lenisRef.value?.scrollTo(0, { immediate: true, force: true })
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
lenis?.destroy()
|
||||
lenis = null
|
||||
lenisRef.value?.destroy()
|
||||
lenisRef.value = null
|
||||
})
|
||||
</script>
|
||||
+187
-14
@@ -187,20 +187,38 @@
|
||||
<CookieBanner />
|
||||
</div>
|
||||
|
||||
<!-- ===== 品牌幕布:滚到最底后继续滑,露出半屏蓝色底 + 白色巨字 GVSDS =====
|
||||
<!-- ===== 品牌幕布:滚到最底后继续滑,露出半屏蓝色底 + 巨字 GVSDS =====
|
||||
幕布固定贴住视口下半屏(z-0),平时被内容层(z-1 + 白底)完整盖住;
|
||||
末尾留半屏透明占位,滚到底时占位区把幕布整块露出来("抽盖子"式的翻页观感)。
|
||||
用 z-0 / z-1 这对正数分层,而不是负 z-index:body 的不透明背景会吃掉负 z-index,
|
||||
幕布就永远看不见了。两者放在内容层之外,否则会被内容层的层叠上下文与白底吃掉。 -->
|
||||
幕布就永远看不见了。两者放在内容层之外,否则会被内容层的层叠上下文与白底吃掉。
|
||||
|
||||
字形不是普通文字,而是 SVG:幕布先是纯蓝背景,露出六成之后五个字母同时起笔,
|
||||
沿着 --font-sans 的字形轮廓把笔画一段段"写"出来,写完再补上实心。
|
||||
每个字母一个独立 <text>:同一元素内 stroke-dasharray 会跨字形连着走,
|
||||
拆开才能五个字同时起笔。轮廓长度由 JS 估算后写进 --dash(见 setupCurtain)。 -->
|
||||
<div class="brand-curtain" aria-hidden="true">
|
||||
<span class="brand-curtain-word">GVSDS</span>
|
||||
<svg ref="curtainSvg" class="brand-curtain-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
<g ref="curtainGroup">
|
||||
<text
|
||||
v-for="(ch, i) in curtainLetters"
|
||||
:key="i"
|
||||
:ref="(el) => setLetterEl(el, i)"
|
||||
class="brand-curtain-letter"
|
||||
:class="{ 'is-drawn': curtainDrawn }"
|
||||
x="0"
|
||||
y="0"
|
||||
>{{ ch }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-curtain-spacer" aria-hidden="true"></div>
|
||||
<div ref="curtainSpacer" class="brand-curtain-spacer" aria-hidden="true"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, inject, nextTick, onMounted, onUnmounted, ref, watch, type ComponentPublicInstance, type Ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type Lenis from 'lenis'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import SiteFooter from '@/components/SiteFooter.vue'
|
||||
import CookieBanner from '@/components/CookieBanner.vue'
|
||||
@@ -211,6 +229,9 @@ import { megaMenuData, type MegaMenuData, type MegaSection, type MegaSubmenuItem
|
||||
const router = useRouter()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
/** App.vue 下发的 Lenis 实例:幕布"一划到底"要驱动它,不能和它抢滚动 */
|
||||
const lenisRef = inject<Ref<Lenis | null> | null>('gvsds-lenis', null)
|
||||
|
||||
/** 顶部导航项 */
|
||||
const navItems = [
|
||||
{ key: 'studio', label: '工作室' },
|
||||
@@ -420,6 +441,123 @@ function hideResultsSoon(): void {
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/** ===== 品牌幕布:沿字形笔画把 GVSDS 写出来 ===== */
|
||||
|
||||
/** 幕布字母。必须是一个字母一个 <text>:SVG 的虚线图案在同一元素内
|
||||
* 会顺着所有字形轮廓连续走,只有拆成独立元素才能五个字同时起笔 */
|
||||
const curtainLetters = ['G', 'V', 'S', 'D', 'S'] as const
|
||||
|
||||
/** 轮廓长度估算系数。getTotalLength() 只对 <path> 这类几何元素生效,<text> 取不到
|
||||
* 真实轮廓长度,只能用字宽 getComputedTextLength() 乘经验系数估:
|
||||
* 大写 D / G 的轮廓周长约为字宽的 4.0 倍、S / V 约 3.0 倍。取 4.2 保证所有笔画都能收尾
|
||||
* (取小了尾部一段永远画不满),代价是笔画少的 S / V 会比 D / G 稍早停笔 */
|
||||
const CURTAIN_DASH_FACTOR = 4.2
|
||||
|
||||
/** 幕布露出这么多比例即起笔(与 <style> 里的 transition 无关,只管时机) */
|
||||
const CURTAIN_DRAW_AT = 0.6
|
||||
|
||||
/** 一划到底:距页面底部不足这么多屏时,下一次向下滑动直接滑到底 */
|
||||
const CURTAIN_GLIDE_ZONE = 0.6
|
||||
const CURTAIN_GLIDE_MS = 1100
|
||||
|
||||
const curtainSvg = ref<SVGSVGElement | null>(null)
|
||||
const curtainGroup = ref<SVGGElement | null>(null)
|
||||
const curtainSpacer = ref<HTMLElement | null>(null)
|
||||
const curtainDrawn = ref(false)
|
||||
|
||||
/** v-for 里的 <text> 引用,setupCurtain 要逐个量身定位 */
|
||||
const letterEls: (SVGTextElement | null)[] = []
|
||||
function setLetterEl(el: Element | ComponentPublicInstance | null, index: number): void {
|
||||
letterEls[index] = el instanceof SVGTextElement ? el : null
|
||||
}
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
let curtainObserver: IntersectionObserver | null = null
|
||||
let curtainGlideUntil = 0
|
||||
|
||||
/**
|
||||
* 幕布初始化(挂载后跑一次):
|
||||
* ① 先等字体就绪——字体没加载完就量,量到的是回退字体的字宽,字母会排错位;
|
||||
* ② 逐个字母量宽:按累计宽度排 x,并按估算的轮廓周长写 --dash(虚线的长与偏移初值);
|
||||
* ③ 按整组实测 bbox 设 viewBox,让五个字母整体铺满这半屏幕布,四周留一点边不贴框。
|
||||
*/
|
||||
async function setupCurtain(): Promise<void> {
|
||||
const svg = curtainSvg.value
|
||||
const els = letterEls.filter((el): el is SVGTextElement => el !== null)
|
||||
if (!svg || els.length === 0) return
|
||||
try {
|
||||
await document.fonts.ready
|
||||
} catch {
|
||||
// 字体接口不可用就按当前字体量(退到系统无衬线,位置仍自洽)
|
||||
}
|
||||
let x = 0
|
||||
els.forEach((el) => {
|
||||
const advance = el.getComputedTextLength()
|
||||
el.setAttribute('x', String(x))
|
||||
el.style.setProperty('--dash', String(advance * CURTAIN_DASH_FACTOR))
|
||||
x += advance
|
||||
})
|
||||
const box = curtainGroup.value?.getBBox()
|
||||
if (box && box.width > 0 && box.height > 0) {
|
||||
const pad = box.width * 0.03
|
||||
svg.setAttribute(
|
||||
'viewBox',
|
||||
`${box.x - pad} ${box.y - pad} ${box.width + pad * 2} ${box.height + pad * 2}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 幕布露出六成以上即起笔;只画一次,来回滚动不重播(反复重画反而闪) */
|
||||
function watchCurtain(): void {
|
||||
if (prefersReducedMotion.matches) {
|
||||
curtainDrawn.value = true
|
||||
return
|
||||
}
|
||||
const spacer = curtainSpacer.value
|
||||
if (!spacer) return
|
||||
curtainObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting && e.intersectionRatio >= CURTAIN_DRAW_AT)) return
|
||||
curtainDrawn.value = true
|
||||
curtainObserver?.disconnect()
|
||||
curtainObserver = null
|
||||
},
|
||||
{ threshold: [CURTAIN_DRAW_AT] }
|
||||
)
|
||||
curtainObserver.observe(spacer)
|
||||
}
|
||||
|
||||
/** 一划到底:幕布已露头(距底不足 0.6 屏)时再一次向下滑动,就直接滑到底,
|
||||
* 省得用户在最后半屏里一点点蹭;顺带让描边动画在整块幕布前完整播完 */
|
||||
function glideToBottom(): void {
|
||||
const now = performance.now()
|
||||
if (now < curtainGlideUntil) return
|
||||
const doc = document.documentElement
|
||||
const remaining = doc.scrollHeight - window.scrollY - window.innerHeight
|
||||
if (remaining <= 0 || remaining > window.innerHeight * CURTAIN_GLIDE_ZONE) return
|
||||
curtainGlideUntil = now + CURTAIN_GLIDE_MS + 300
|
||||
const top = doc.scrollHeight
|
||||
const lenis = lenisRef?.value
|
||||
if (lenis) lenis.scrollTo(top, { duration: CURTAIN_GLIDE_MS / 1000 })
|
||||
else window.scrollTo({ top, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
/** 滚轮向下即触发;监听用 passive——不拦默认行为,交给 Lenis 处理滚动,我们只负责改目标 */
|
||||
function onCurtainWheel(e: WheelEvent): void {
|
||||
if (e.deltaY > 0) glideToBottom()
|
||||
}
|
||||
|
||||
/** 触屏:手指上移 = 页面向下滚 */
|
||||
let lastTouchY = 0
|
||||
function onCurtainTouchStart(e: TouchEvent): void {
|
||||
lastTouchY = e.touches[0]?.clientY ?? 0
|
||||
}
|
||||
function onCurtainTouchMove(e: TouchEvent): void {
|
||||
const y = e.touches[0]?.clientY ?? lastTouchY
|
||||
if (y < lastTouchY) glideToBottom()
|
||||
lastTouchY = y
|
||||
}
|
||||
|
||||
/** 滚动时 header 透明/白底切换(对齐 nav.js)
|
||||
* 仅 www 首页(HomeView)在页面顶部时透明,其余页面保持白底不透明 */
|
||||
function handleScroll(): void {
|
||||
@@ -458,11 +596,21 @@ onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
handleScroll()
|
||||
loadAvatar()
|
||||
// 幕布:先量字形排好字与 viewBox,再盯着露头起笔;滑动监听用 passive,不拦默认滚动
|
||||
setupCurtain()
|
||||
watchCurtain()
|
||||
window.addEventListener('wheel', onCurtainWheel, { passive: true })
|
||||
window.addEventListener('touchstart', onCurtainTouchStart, { passive: true })
|
||||
window.addEventListener('touchmove', onCurtainTouchMove, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('wheel', onCurtainWheel)
|
||||
window.removeEventListener('touchstart', onCurtainTouchStart)
|
||||
window.removeEventListener('touchmove', onCurtainTouchMove)
|
||||
if (panelObserver) panelObserver.disconnect()
|
||||
if (curtainObserver) curtainObserver.disconnect()
|
||||
if (heightRaf !== null) cancelAnimationFrame(heightRaf)
|
||||
})
|
||||
|
||||
@@ -572,18 +720,43 @@ watch(
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-curtain-word {
|
||||
/* 幕布内容:一个 SVG 装 5 个字母,viewBox 由 JS 按实测字形 bbox 设,五个字整体铺满这半屏;
|
||||
字形取自 --font-sans,所以笔画是"按字体"来的,不是画好的图形 */
|
||||
.brand-curtain-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 每个字母一个独立 <text>,虚线它自己一套;--dash 由 JS 按估算的轮廓周长写入 */
|
||||
.brand-curtain-letter {
|
||||
font-family: var(--font-sans);
|
||||
/* 字号同时受两个方向的约束:横向 5 个字母约 3.6em 不能超出视口,纵向要撑满这半屏幕布。
|
||||
取两者较小值 → 宽屏时高度顶满、窄屏时宽度顶满(vh 那行是 svh 不支持时的回退) */
|
||||
font-size: min(26vw, 40vh);
|
||||
font-size: min(26vw, 40svh);
|
||||
font-weight: 900;
|
||||
/* 不用 line-height < 1:字形会超出内容盒(配 background-clip 时会被裁掉一截) */
|
||||
line-height: 1;
|
||||
/* 用户单位字号:只决定量出来的字宽与描边粗细的比例,最终大小交给 viewBox 缩放 */
|
||||
font-size: 200px;
|
||||
letter-spacing: -0.015em;
|
||||
white-space: nowrap;
|
||||
color: #ffffff;
|
||||
fill: #ffffff;
|
||||
fill-opacity: 0;
|
||||
stroke: #ffffff;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
/* 量完之前整组不可见:幕布先是纯蓝背景,起笔后才把笔画一段段显出来 */
|
||||
stroke-opacity: 0;
|
||||
stroke-dasharray: var(--dash, 0);
|
||||
stroke-dashoffset: var(--dash, 0);
|
||||
}
|
||||
|
||||
/* 起笔:虚线偏移归零 → 描边沿着字形轮廓把笔画画出来;随后实心补上,回到原来的实心白字。
|
||||
transition 只写在 .is-drawn 里:量完写 --dash 那一次改动不能带过渡,
|
||||
否则偏移会从 0 缓到量好的值(等于提前把字画一遍) */
|
||||
.brand-curtain-letter.is-drawn {
|
||||
stroke-opacity: 1;
|
||||
stroke-dashoffset: 0;
|
||||
fill-opacity: 1;
|
||||
transition:
|
||||
stroke-dashoffset 2s cubic-bezier(0.33, 0, 0.55, 1),
|
||||
fill-opacity 0.5s ease 1.65s;
|
||||
}
|
||||
|
||||
/* 末尾占位:与幕布等高(半屏,svh 避开移动端地址栏收缩导致的跳动),透明 → 露出幕布 */
|
||||
|
||||
Reference in new issue
Block a user