Files
NewBlog/src/components/hero.tsx
T
dsh 2c876f8776 彩蛋调整:文案改回原句,去掉悬停变色/下划线,那句话改回纯文本
按站主要求三处调整:

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) 手机常显
2026-09-16 18:24:49 +08:00

249 lines
9.5 KiB
TypeScript
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.
"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>
)
}