按站主要求三处调整: 1) 文案改回原文 他还需要一个诗意的世界。 → 他还应该拥有诗意的世界。 (仍是 12 字,和最长那句 15 字的关系不变,固定字号不受影响) 2) 去掉悬停的变色与下划线 现在那句话悬停时完全不变化,保持纯排版。 删掉 .hero-quote-link 的两条规则,以及 reduced-motion 里对它的引用。 3) 那句话不再可点 去掉 h1 里的 <a>,文字恢复成纯文本。豆瓣链接只剩卡片里那一处。 交互全部落在署名上:悬停/聚焦淡入署名 → 点署名展开卡片 → 卡片里去豆瓣。 这样读的时候不会误触跳走。 署名按钮补了 aria-label(「查看这句话的出处:王小波《万寿寺》」), 因为现在它是唯一的交互元素,读屏用户需要知道它是干嘛的。 验证:pnpm build 退出码 0 - 新文案在 hero.*.js 里,旧文案无残留 - dist/index.html 里豆瓣链接只剩 1 处、h1 内为纯 <span>、无 hero-quote-link - 产物 CSS 从 16 条 hero-quote 规则减到 14 条, 仍保留署名淡入、展开态常显、@media(hover:none) 手机常显
249 lines
9.5 KiB
TypeScript
249 lines
9.5 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useRef, useState, useCallback } from "react"
|
||
import { site } from "@/data/site"
|
||
|
||
/**
|
||
* 打字机文案。**只放长度接近的短句** —— 字号是固定的,靠"文案等长"来
|
||
* 保证不溢出,而不是靠运行时测量(见下方 HERO_SIZE_CLASSES 的说明)。
|
||
* 原来那四句是 14/11/4/4 字,长短差 3.5 倍,只能靠自适应字号兜底,
|
||
* 结果是每换一句首屏最大的字就跳 1.5 倍。
|
||
*/
|
||
/** 王小波的豆瓣人物页。点击那句话或卡片里的链接都去这里。 */
|
||
const DOUBAN_URL = "https://www.douban.com/personage/27480512/"
|
||
|
||
const phrases = [
|
||
{ prefix: "一个人只拥有此生此世", highlight: "是不够的," },
|
||
{ prefix: "他还应该拥有", highlight: "诗意的世界。" },
|
||
]
|
||
|
||
/**
|
||
* 首屏大字字号:**固定按视口缩放,不随句子长度变**。
|
||
*
|
||
* 这是从 Firefly 的 banner 学来的做法:那边 subtitleSize 写死 3rem,
|
||
* CSS 里再用 clamp() 跟着视口缩放,配两条长度接近的文案就永远不会溢出,
|
||
* 也就不需要任何运行时测量。
|
||
*
|
||
* 取值依据:最长那句 15 个全角字 ≈ 14.6em(含 tracking-tight 的 -0.025em),
|
||
* 加光标约 0.3em + 2px,所以可用宽度 / 14.9 就是字号上限。
|
||
* 320px 屏 → 约 18px 390px → 约 22px 768px → 约 43px
|
||
* 1024px → 约 57px 1280px 及以上 → 68px(clamp 封顶)
|
||
* 移动端刻意压小以保证 15 个字一行放得下;桌面端放大到 68px 更醒目。
|
||
*/
|
||
const HERO_SIZE_CLASSES = "text-[clamp(1.125rem,5.6vw,4rem)]"
|
||
|
||
export function Hero() {
|
||
const [phraseIndex, setPhraseIndex] = useState(0)
|
||
/** 「作者卡片」展开状态:点署名展开,再点收起 */
|
||
const [cardOpen, setCardOpen] = useState(false)
|
||
const [displayText, setDisplayText] = useState("")
|
||
const [isDeleting, setIsDeleting] = useState(false)
|
||
// Pause the typewriter loop when the hero is offscreen or the tab is hidden;
|
||
// otherwise the setTimeout chain keeps re-rendering React state while the
|
||
// user reads other parts of the home page.
|
||
const [isActive, setIsActive] = useState(true)
|
||
const sectionRef = useRef<HTMLElement | null>(null)
|
||
|
||
useEffect(() => {
|
||
const onVisibility = () => {
|
||
if (document.hidden) setIsActive(false)
|
||
else if (sectionRef.current) {
|
||
const rect = sectionRef.current.getBoundingClientRect()
|
||
setIsActive(rect.bottom > 0 && rect.top < window.innerHeight)
|
||
}
|
||
}
|
||
document.addEventListener("visibilitychange", onVisibility)
|
||
|
||
let observer: IntersectionObserver | null = null
|
||
if (sectionRef.current && typeof IntersectionObserver !== "undefined") {
|
||
observer = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
setIsActive(entry.isIntersecting && !document.hidden)
|
||
}
|
||
},
|
||
{ threshold: 0 },
|
||
)
|
||
observer.observe(sectionRef.current)
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", onVisibility)
|
||
observer?.disconnect()
|
||
}
|
||
}, [])
|
||
|
||
const getTypeSpeed = useCallback(() => {
|
||
// Random variation for natural typing rhythm (50-90ms)
|
||
return 50 + Math.random() * 40
|
||
}, [])
|
||
|
||
const getDeleteSpeed = useCallback(() => {
|
||
// Faster, more consistent deletion (25-40ms)
|
||
return 25 + Math.random() * 15
|
||
}, [])
|
||
|
||
const innerTimerRef = useRef<number | null>(null)
|
||
|
||
const typeWriter = useCallback(() => {
|
||
const currentPhrase = phrases[phraseIndex]
|
||
const fullText = currentPhrase.prefix + currentPhrase.highlight
|
||
|
||
if (innerTimerRef.current !== null) {
|
||
window.clearTimeout(innerTimerRef.current)
|
||
innerTimerRef.current = null
|
||
}
|
||
|
||
if (!isDeleting) {
|
||
if (displayText.length < fullText.length) {
|
||
innerTimerRef.current = window.setTimeout(() => {
|
||
setDisplayText(fullText.slice(0, displayText.length + 1))
|
||
}, getTypeSpeed())
|
||
} else {
|
||
innerTimerRef.current = window.setTimeout(() => setIsDeleting(true), 1500)
|
||
}
|
||
} else {
|
||
if (displayText.length > 0) {
|
||
innerTimerRef.current = window.setTimeout(() => {
|
||
setDisplayText(displayText.slice(0, -1))
|
||
}, getDeleteSpeed())
|
||
} else {
|
||
setIsDeleting(false)
|
||
setPhraseIndex((prev) => (prev + 1) % phrases.length)
|
||
}
|
||
}
|
||
}, [displayText, isDeleting, phraseIndex, getTypeSpeed, getDeleteSpeed])
|
||
|
||
useEffect(() => {
|
||
if (!isActive) return
|
||
const timer = window.setTimeout(typeWriter, 50)
|
||
return () => {
|
||
window.clearTimeout(timer)
|
||
if (innerTimerRef.current !== null) {
|
||
window.clearTimeout(innerTimerRef.current)
|
||
innerTimerRef.current = null
|
||
}
|
||
}
|
||
}, [isActive, typeWriter])
|
||
|
||
const renderText = () => {
|
||
const currentPhrase = phrases[phraseIndex]
|
||
const prefixLength = currentPhrase.prefix.length
|
||
|
||
if (displayText.length <= prefixLength) {
|
||
return <span>{displayText}</span>
|
||
} else {
|
||
return (
|
||
<>
|
||
<span>{currentPhrase.prefix}</span>
|
||
<span className="text-primary">{displayText.slice(prefixLength)}</span>
|
||
</>
|
||
)
|
||
}
|
||
}
|
||
|
||
const handleBrowseClick = useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||
event.preventDefault()
|
||
const target = document.getElementById("home-main")
|
||
if (!target) return
|
||
target.scrollIntoView({ behavior: "smooth", block: "start" })
|
||
if (history.replaceState) {
|
||
history.replaceState({}, "", "#home-main")
|
||
}
|
||
}, [])
|
||
|
||
return (
|
||
<section ref={sectionRef} className="relative min-h-[100svh] flex items-center justify-center px-6 bg-surface-subtle">
|
||
{/* 容器给到 max-w-5xl:15 字 × 64px = 954px,4xl(896px) 装不下 */}
|
||
<div className="max-w-5xl mx-auto text-center">
|
||
{/* 入场动画走 CSS 而不是 JS 控制的 opacity:
|
||
原来 SSR 出来带 opacity-0,要等 React hydrate 才变可见,
|
||
慢网络下首屏是一片空白,LCP 也被拖后。 */}
|
||
<div className="hero-enter">
|
||
<p className="text-[0.65rem] sm:text-xs tracking-[0.4em] text-muted-foreground/60 uppercase mb-8">
|
||
{site.tagline}
|
||
</p>
|
||
|
||
{/*
|
||
彩蛋:这句话出自王小波《万寿寺》。
|
||
- 平时完全看不出可点,那句话本身不动(纯粹的排版)
|
||
- 悬停/聚焦:下方淡入署名(无 hover 的设备常显署名)
|
||
- 点署名 → 展开一张极简作者卡片,卡片里可以去豆瓣
|
||
|
||
那句话故意不做成链接:点击文字不跳转,交互只落在署名上,
|
||
免得读的时候误触跳走。
|
||
*/}
|
||
<div className="hero-quote">
|
||
<h1
|
||
className={`hero-typewriter font-sans ${HERO_SIZE_CLASSES} font-bold tracking-tight text-foreground leading-[1.15] min-h-[1.2em] whitespace-nowrap`}
|
||
>
|
||
{renderText()}
|
||
</h1>
|
||
|
||
{/* 固定高度的一行,内容 hover 才淡入 —— 这样展开卡片时按钮不会跳 */}
|
||
<div className="hero-quote-credit-row">
|
||
<button
|
||
type="button"
|
||
className="hero-quote-credit"
|
||
onClick={() => setCardOpen((open) => !open)}
|
||
aria-expanded={cardOpen}
|
||
aria-controls="hero-quote-card"
|
||
aria-label="查看这句话的出处:王小波《万寿寺》"
|
||
>
|
||
—— 王小波 ·《万寿寺》
|
||
</button>
|
||
</div>
|
||
|
||
<div id="hero-quote-card" className="hero-quote-card" data-open={cardOpen}>
|
||
<div className="hero-quote-card-inner">
|
||
<span className="hero-quote-card-name">
|
||
王小波<span className="hero-quote-card-years">1952–1997</span>
|
||
</span>
|
||
<a
|
||
className="hero-quote-card-link"
|
||
href={DOUBAN_URL}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
豆瓣主页 <span aria-hidden="true">→</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||
<a
|
||
href="#home-main"
|
||
onClick={handleBrowseClick}
|
||
className="group flex items-center gap-2 px-8 py-3 bg-foreground text-background rounded-full font-medium text-sm hover:bg-foreground/90 transition-all duration-300"
|
||
>
|
||
浏览文章
|
||
<svg
|
||
className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||
</svg>
|
||
</a>
|
||
<a
|
||
href="/about/"
|
||
className="px-8 py-3 border border-border rounded-full font-medium text-sm text-foreground hover:border-foreground/40 transition-all duration-300"
|
||
>
|
||
关于我
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scroll indicator */}
|
||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2">
|
||
<div className="w-px h-12 bg-gradient-to-b from-border to-transparent relative">
|
||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-primary animate-bounce" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|