"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(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(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 {displayText} } else { return ( <> {currentPhrase.prefix} {displayText.slice(prefixLength)} ) } } const handleBrowseClick = useCallback((event: React.MouseEvent) => { 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 (
{/* 容器给到 max-w-5xl:15 字 × 64px = 954px,4xl(896px) 装不下 */}
{/* 入场动画走 CSS 而不是 JS 控制的 opacity: 原来 SSR 出来带 opacity-0,要等 React hydrate 才变可见, 慢网络下首屏是一片空白,LCP 也被拖后。 */}

{site.tagline}

{/* 彩蛋:这句话出自王小波《万寿寺》。 - 平时完全看不出可点,那句话本身不动(纯粹的排版) - 悬停/聚焦:下方淡入署名(无 hover 的设备常显署名) - 点署名 → 展开一张极简作者卡片,卡片里可以去豆瓣 那句话故意不做成链接:点击文字不跳转,交互只落在署名上, 免得读的时候误触跳走。 */}

{renderText()}

{/* 固定高度的一行,内容 hover 才淡入 —— 这样展开卡片时按钮不会跳 */}
王小波1952–1997 豆瓣主页
{/* Scroll indicator */}
) }