diff --git a/src/components/hero.tsx b/src/components/hero.tsx index f3c5da5..b61a3b0 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -3,29 +3,33 @@ import { useEffect, useRef, useState, useCallback } from "react" import { site } from "@/data/site" +/** + * 打字机文案。**只放长度接近的短句** —— 字号是固定的,靠"文案等长"来 + * 保证不溢出,而不是靠运行时测量(见下方 HERO_SIZE_CLASSES 的说明)。 + * 原来那四句是 14/11/4/4 字,长短差 3.5 倍,只能靠自适应字号兜底, + * 结果是每换一句首屏最大的字就跳 1.5 倍。 + */ const phrases = [ - { prefix: "一个人只拥有此生此世", highlight: "是不够的" }, - { prefix: "他还需要一个", highlight: "诗意的世界" }, - { prefix: "记录", highlight: "思考" }, - { prefix: "总结", highlight: "生活" }, + { prefix: "一个人只拥有此生此世", highlight: "是不够的," }, + { prefix: "他还需要一个", highlight: "诗意的世界。" }, ] /** - * 首屏大字的「设计字号」:手机 / sm / md / lg 四档。 - * 自适应只会在此基础上**缩小**,不会放大 —— 短句保持原来的视觉分量, - * 只有放不下的长句才让步。改这里两处要同步(h1 和下面量尺寸的探针)。 + * 首屏大字字号:**固定按视口缩放,不随句子长度变**。 + * + * 这是从 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-[min(10.5vw,3rem)] sm:text-6xl md:text-7xl lg:text-8xl" - -/** 缩放的绝对下限,防止句子太长时缩到读不清 */ -const HERO_MIN_FONT = 20 - -/** 光标的占位:ml-2 的 8px + w-[0.08em],算字号时要一起扣掉 */ -const CURSOR_GAP_PX = 8 -const CURSOR_EM = 0.08 +const HERO_SIZE_CLASSES = "text-[clamp(1.125rem,5.6vw,4rem)]" export function Hero() { - const [mounted, setMounted] = useState(false) const [phraseIndex, setPhraseIndex] = useState(0) const [displayText, setDisplayText] = useState("") const [isDeleting, setIsDeleting] = useState(false) @@ -35,10 +39,6 @@ export function Hero() { const [isActive, setIsActive] = useState(true) const sectionRef = useRef(null) - useEffect(() => { - setMounted(true) - }, []) - useEffect(() => { const onVisibility = () => { if (document.hidden) setIsActive(false) @@ -110,7 +110,7 @@ export function Hero() { }, [displayText, isDeleting, phraseIndex, getTypeSpeed, getDeleteSpeed]) useEffect(() => { - if (!mounted || !isActive) return + if (!isActive) return const timer = window.setTimeout(typeWriter, 50) return () => { window.clearTimeout(timer) @@ -119,7 +119,7 @@ export function Hero() { innerTimerRef.current = null } } - }, [mounted, isActive, typeWriter]) + }, [isActive, typeWriter]) const renderText = () => { const currentPhrase = phrases[phraseIndex] @@ -147,120 +147,25 @@ export function Hero() { } }, []) - /* - * 字号自适应。 - * - * 原来写死 text-[min(10.5vw,3rem)] sm:text-6xl …,配合 whitespace-nowrap: - * 句子一长(旧文案约 9.5 字宽就已经临界)就会横向溢出,手机上尤其明显。 - * - * 现在按「这一句的完整文本」实际量一次宽度来定字号,所以: - * - 任何长度的句子都恰好占满可用宽度,不溢出不换行 - * - 打字机逐字出现时不会重排(量的是完整句子,不是当前已打出的部分) - * - 字号只在句子切换时变一次,那一刻 h1 是空的,看不出跳动 - */ - const fullText = phrases[phraseIndex].prefix + phrases[phraseIndex].highlight - - const h1Ref = useRef(null) - const measureRef = useRef(null) - const designRef = useRef(null) - const [fontSize, setFontSize] = useState(undefined) - - const fitFontSize = useCallback(() => { - const h1 = h1Ref.current - const meas = measureRef.current - const probe = designRef.current - if (!h1 || !meas || !probe) return - - const available = h1.parentElement?.clientWidth ?? 0 - if (available <= 0) return - - // 测量用的 span 固定按 100px 渲染,所以 宽度/100 就是「1 个字号单位占几 px」 - const widthAt100 = meas.getBoundingClientRect().width - if (widthAt100 <= 0) return - - // 探针带着和 h1 一样的响应式字号类,用它的计算值当上限, - // 这样断点逻辑只写在 class 里一处,不用在 JS 里硬编码 - const design = Number.parseFloat(window.getComputedStyle(probe).fontSize) - if (!Number.isFinite(design) || design <= 0) return - - // 光标也占宽度(ml-2 + w-[0.08em]),不扣掉的话右边会顶出去一点 - const perEm = widthAt100 / 100 + CURSOR_EM - const fitted = Math.max(0, (available - CURSOR_GAP_PX) / perEm) - - const size = Math.min(design, Math.max(HERO_MIN_FONT, fitted)) - setFontSize(`${size.toFixed(1)}px`) - }, []) - - useEffect(() => { - fitFontSize() - - const parent = h1Ref.current?.parentElement - let observer: ResizeObserver | null = null - if (parent && typeof ResizeObserver !== "undefined") { - observer = new ResizeObserver(fitFontSize) - observer.observe(parent) - } - - // 网络字体加载完成后字宽会变,重新量一次 - document.fonts?.ready.then(fitFontSize).catch(() => {}) - - return () => observer?.disconnect() - }, [fitFontSize, fullText]) - return (
-
-
+ {/* 容器给到 max-w-5xl:15 字 × 64px = 954px,4xl(896px) 装不下 */} +
+ {/* 入场动画走 CSS 而不是 JS 控制的 opacity: + 原来 SSR 出来带 opacity-0,要等 React hydrate 才变可见, + 慢网络下首屏是一片空白,LCP 也被拖后。 */} +

{site.tagline}

+ {/* 光标是 CSS ::after 画的一个竖线字符,见 styles/global.css 的 .hero-typewriter */}

{renderText()} -

- {/* - 两个量尺寸用的隐藏元素,外层 h-0 + overflow-hidden 保证它们 - 不参与布局、也不会撑出横向滚动条: - - measureRef:按 100px 渲染当前这句完整文本,用来算实际字宽 - - designRef :带着和 h1 相同的响应式字号类,用来读当前断点的设计字号 - */} - - -
-

- 「我不知道我在想什么,直到我读到我写的东西。」 -

-
- —— Flannery O'Connor -
-
-