首屏打字机改为固定字号 + CSS 光标,并修掉首屏白屏
按 Firefly banner 的做法重做,三件事:
1) 字号固定,不随句子长度变
- 文案从 4 句(14/11/4/4 字,差 3.5 倍)压成 2 句长度接近的王小波
- 字号用 text-[clamp(1.125rem,5.6vw,4rem)],只跟视口缩放
· 手机 390px → 21.8px(15 个字一行刚好放得下)
· 桌面 1280px → 64px(比 Firefly 的 3rem 更大更醒目)
- 容器 max-w-4xl(896px) → max-w-5xl(1024px):15 字 × 64px = 954px,4xl 装不下
- 实测 320/360/390/430/640/768/1024/1280/1920 全部放得下,无溢出
2) 光标改成 CSS ::after
- 删掉整套运行时测量:measureRef / designRef / fontSize state /
fitFontSize / ResizeObserver / CURSOR_EM / CURSOR_GAP_PX / 两个隐藏量具
- hero.tsx 298 → 208 行,两个文件净减 52 行
- 顺带消除测量误差,光标跟着字号自动缩放
3) 去掉和打字机重复的 Flannery O'Connor 引语
另外修掉两个自己发现的问题:
- 【真 bug】首屏在 JS 加载前是隐形的
SSR 出来的包裹层带 opacity-0,要等 React hydrate 才变可见,
慢网络下第一屏是白屏、LCP 被拖后。改成 CSS 入场动画(.hero-enter),
SSR 即可见;顺带删掉只为它存在的 mounted state
- 【算错】第一版 clamp 封顶写了 4.25rem(68px),但容器 896px 装不下
1013px,≥1280px 会溢出。一起修掉了
验证:pnpm build 退出码 0;dist 里已无 hero-cursor span、无隐藏量具、
无 Flannery 引语,h1 class 为 hero-typewriter + clamp 字号
This commit is contained in:
+30
-125
@@ -3,29 +3,33 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react"
|
import { useEffect, useRef, useState, useCallback } from "react"
|
||||||
import { site } from "@/data/site"
|
import { site } from "@/data/site"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打字机文案。**只放长度接近的短句** —— 字号是固定的,靠"文案等长"来
|
||||||
|
* 保证不溢出,而不是靠运行时测量(见下方 HERO_SIZE_CLASSES 的说明)。
|
||||||
|
* 原来那四句是 14/11/4/4 字,长短差 3.5 倍,只能靠自适应字号兜底,
|
||||||
|
* 结果是每换一句首屏最大的字就跳 1.5 倍。
|
||||||
|
*/
|
||||||
const phrases = [
|
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_SIZE_CLASSES = "text-[clamp(1.125rem,5.6vw,4rem)]"
|
||||||
|
|
||||||
/** 缩放的绝对下限,防止句子太长时缩到读不清 */
|
|
||||||
const HERO_MIN_FONT = 20
|
|
||||||
|
|
||||||
/** 光标的占位:ml-2 的 8px + w-[0.08em],算字号时要一起扣掉 */
|
|
||||||
const CURSOR_GAP_PX = 8
|
|
||||||
const CURSOR_EM = 0.08
|
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero() {
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
const [phraseIndex, setPhraseIndex] = useState(0)
|
const [phraseIndex, setPhraseIndex] = useState(0)
|
||||||
const [displayText, setDisplayText] = useState("")
|
const [displayText, setDisplayText] = useState("")
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
@@ -35,10 +39,6 @@ export function Hero() {
|
|||||||
const [isActive, setIsActive] = useState(true)
|
const [isActive, setIsActive] = useState(true)
|
||||||
const sectionRef = useRef<HTMLElement | null>(null)
|
const sectionRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onVisibility = () => {
|
const onVisibility = () => {
|
||||||
if (document.hidden) setIsActive(false)
|
if (document.hidden) setIsActive(false)
|
||||||
@@ -110,7 +110,7 @@ export function Hero() {
|
|||||||
}, [displayText, isDeleting, phraseIndex, getTypeSpeed, getDeleteSpeed])
|
}, [displayText, isDeleting, phraseIndex, getTypeSpeed, getDeleteSpeed])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || !isActive) return
|
if (!isActive) return
|
||||||
const timer = window.setTimeout(typeWriter, 50)
|
const timer = window.setTimeout(typeWriter, 50)
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timer)
|
window.clearTimeout(timer)
|
||||||
@@ -119,7 +119,7 @@ export function Hero() {
|
|||||||
innerTimerRef.current = null
|
innerTimerRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [mounted, isActive, typeWriter])
|
}, [isActive, typeWriter])
|
||||||
|
|
||||||
const renderText = () => {
|
const renderText = () => {
|
||||||
const currentPhrase = phrases[phraseIndex]
|
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<HTMLHeadingElement | null>(null)
|
|
||||||
const measureRef = useRef<HTMLSpanElement | null>(null)
|
|
||||||
const designRef = useRef<HTMLSpanElement | null>(null)
|
|
||||||
const [fontSize, setFontSize] = useState<string | undefined>(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 (
|
return (
|
||||||
<section ref={sectionRef} className="relative min-h-[100svh] flex items-center justify-center px-6 bg-surface-subtle">
|
<section ref={sectionRef} className="relative min-h-[100svh] flex items-center justify-center px-6 bg-surface-subtle">
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
{/* 容器给到 max-w-5xl:15 字 × 64px = 954px,4xl(896px) 装不下 */}
|
||||||
<div
|
<div className="max-w-5xl mx-auto text-center">
|
||||||
className={`transition-all duration-1000 ease-out ${
|
{/* 入场动画走 CSS 而不是 JS 控制的 opacity:
|
||||||
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
原来 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">
|
<p className="text-[0.65rem] sm:text-xs tracking-[0.4em] text-muted-foreground/60 uppercase mb-8">
|
||||||
{site.tagline}
|
{site.tagline}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* 光标是 CSS ::after 画的一个竖线字符,见 styles/global.css 的 .hero-typewriter */}
|
||||||
<h1
|
<h1
|
||||||
ref={h1Ref}
|
className={`hero-typewriter font-sans ${HERO_SIZE_CLASSES} font-bold tracking-tight text-foreground leading-[1.15] mb-12 min-h-[1.2em] whitespace-nowrap`}
|
||||||
style={fontSize ? { fontSize } : undefined}
|
|
||||||
className={`font-sans ${HERO_SIZE_CLASSES} font-bold tracking-tight text-foreground leading-[1.1] mb-8 min-h-[1.2em] whitespace-nowrap transition-[font-size] duration-300 ease-out`}
|
|
||||||
>
|
>
|
||||||
{renderText()}
|
{renderText()}
|
||||||
<span
|
|
||||||
className="hero-cursor inline-block w-[0.08em] h-[0.9em] bg-foreground align-middle rounded-sm ml-2 -translate-y-1"
|
|
||||||
/>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/*
|
|
||||||
两个量尺寸用的隐藏元素,外层 h-0 + overflow-hidden 保证它们
|
|
||||||
不参与布局、也不会撑出横向滚动条:
|
|
||||||
- measureRef:按 100px 渲染当前这句完整文本,用来算实际字宽
|
|
||||||
- designRef :带着和 h1 相同的响应式字号类,用来读当前断点的设计字号
|
|
||||||
*/}
|
|
||||||
<div className="absolute inset-x-0 top-0 h-0 overflow-hidden" aria-hidden="true">
|
|
||||||
<span
|
|
||||||
ref={measureRef}
|
|
||||||
className="absolute left-0 top-0 whitespace-nowrap font-sans font-bold tracking-tight invisible"
|
|
||||||
style={{ fontSize: "100px" }}
|
|
||||||
>
|
|
||||||
{fullText}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
ref={designRef}
|
|
||||||
className={`absolute left-0 top-0 whitespace-nowrap font-sans font-bold tracking-tight invisible ${HERO_SIZE_CLASSES}`}
|
|
||||||
>
|
|
||||||
字
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<blockquote className="max-w-xl mx-auto mb-10">
|
|
||||||
<p className="text-content-secondary text-base sm:text-lg leading-relaxed italic">
|
|
||||||
「我不知道我在想什么,直到我读到我写的东西。」
|
|
||||||
</p>
|
|
||||||
<footer className="mt-2 text-sm text-muted-foreground/70">
|
|
||||||
—— Flannery O'Connor
|
|
||||||
</footer>
|
|
||||||
</blockquote>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="#home-main"
|
href="#home-main"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"updatedAt": "2026-09-15T10:00:22.188Z",
|
"updatedAt": "2026-09-16T09:46:47.801Z",
|
||||||
"source": "weread",
|
"source": "weread",
|
||||||
"books": [
|
"books": [
|
||||||
{
|
{
|
||||||
@@ -8,12 +8,12 @@
|
|||||||
"author": "布尔加科夫",
|
"author": "布尔加科夫",
|
||||||
"category": "文学-外国文学",
|
"category": "文学-外国文学",
|
||||||
"cover": "https://wfqqreader-1252317822.image.myqcloud.com/cover/299/26678299/t6_26678299.jpg",
|
"cover": "https://wfqqreader-1252317822.image.myqcloud.com/cover/299/26678299/t6_26678299.jpg",
|
||||||
"progress": 30,
|
"progress": 31,
|
||||||
"finished": false,
|
"finished": false,
|
||||||
"lastReadAt": "2026-09-13T15:08:26.000Z",
|
"lastReadAt": "2026-09-15T16:12:26.000Z",
|
||||||
"startedAt": "2026-09-04T16:07:07.000Z",
|
"startedAt": "2026-09-04T16:07:07.000Z",
|
||||||
"finishedAt": null,
|
"finishedAt": null,
|
||||||
"readingSeconds": 4030,
|
"readingSeconds": 4209,
|
||||||
"deepLink": "https://weread.qq.com/book-detail?type=1&v=34c329807197141b34cf1db"
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=34c329807197141b34cf1db"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+51
-8
@@ -745,18 +745,61 @@ a.card-github:hover .github-logo {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hero-cursor-blink {
|
/* 首屏入场。走 CSS 不用 JS 控制 opacity —— 否则 SSR 出来的 HTML 带
|
||||||
0%,
|
opacity-0,要等 hydrate 才可见,慢网络下第一屏是白屏。 */
|
||||||
100% {
|
@keyframes hero-enter {
|
||||||
opacity: 1;
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(2rem);
|
||||||
}
|
}
|
||||||
50% {
|
to {
|
||||||
opacity: 0.35;
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-cursor {
|
.hero-enter {
|
||||||
animation: hero-cursor-blink 0.9s ease-in-out infinite;
|
animation: hero-enter 900ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-enter {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-cursor-blink {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
51%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 首屏打字机的光标。
|
||||||
|
*
|
||||||
|
* 用伪元素画一个竖线字符,而不是插一个 <span>:
|
||||||
|
* - 不需要额外 DOM,也不用在 JS 里算宽高
|
||||||
|
* - 字号一变它自己跟着缩放(原来是写死 px 再换算 em)
|
||||||
|
* - 不占额外宽度,避免把 15 个字顶出容器
|
||||||
|
* 做法取自 Firefly 的 TypewriterText。
|
||||||
|
*/
|
||||||
|
.hero-typewriter::after {
|
||||||
|
content: "|";
|
||||||
|
margin-left: 0.06em;
|
||||||
|
font-weight: 300;
|
||||||
|
opacity: 0.85;
|
||||||
|
animation: hero-cursor-blink 1.05s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-typewriter::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— 偶得 (thoughts) view ——— */
|
/* ——— 偶得 (thoughts) view ——— */
|
||||||
|
|||||||
Reference in New Issue
Block a user