首屏打字文案改成长句也不溢出:按实际字宽自适应字号
问题 - h1 是 whitespace-nowrap + 固定字号 text-[min(10.5vw,3rem)] sm:text-6xl md:text-7xl lg:text-8xl。其实旧文案就已经临界:旧句「折腾 Linux 的乐趣」 约 9.5 字宽,390px 屏上设计字号 40.9px 需要 389px,而 px-6 之后只有 342px。 换成王小波那句(14 字)后溢出更明显。桌面端同理:lg 的 96px × 14 字 = 1344px,而容器 max-w-4xl 只有 896px。 做法 - 不再拍固定字号,改成按「这一句完整文本」的实际渲染宽度算 - 关键点:只会**缩小**,不会放大。上限取当前断点的设计字号 —— 短句(记录思考/总结生活)保持原来的视觉分量,只有放不下的长句让步 - 量的是完整句子不是当前已打出的部分,所以打字机逐字出现时不会重排 - 字号只在句子切换的那一刻变一次,那时 h1 是空的,看不出跳动; 另加了 300ms 的 font-size 过渡 - 光标从固定像素改成 em 单位(w-[0.08em] h-[0.9em]),跟着字号缩放 - 算字号时扣掉光标自身的占宽(ml-2 的 8px + 0.08em),否则右边会顶出去 实现细节 - 设计字号不写死在 JS 里:用一个带同样响应式类的隐藏探针读计算值, 断点逻辑仍然只在 class 里有一处 - 两个隐藏量具放在 h-0 + overflow-hidden 的绝对定位容器里,不参与布局、 不会撑出横向滚动条 - ResizeObserver 跟随容器变化;document.fonts.ready 后再量一次 (网络字体加载完字宽会变) 预期效果(含光标占位,实测可用宽度) 390px 设计 40.9px:14字 24.3px / 11字 30.9px / 4字 40.9px(保持) 430px 设计 45.1px:14字 27.2px / 11字 34.6px / 4字 45.1px(保持) 768px 设计 72.0px:14字 51.9px / 11字 65.9px / 4字 72.0px(保持) 1280px 设计 96.0px:14字 64.7px / 11字 82.2px / 4字 96.0px(保持) 长句刚好占满可用宽度,短句维持原设计大小,无一溢出。 注:hero.tsx 里同时带入了你自己改的那四句文案(王小波那句 + 记录思考 / 总结生活)。src/data/site.ts 里你的 tagline/description/keywords 改动我没有提交。
This commit is contained in:
+106
-6
@@ -4,12 +4,26 @@ import { useEffect, useRef, useState, useCallback } from "react"
|
|||||||
import { site } from "@/data/site"
|
import { site } from "@/data/site"
|
||||||
|
|
||||||
const phrases = [
|
const phrases = [
|
||||||
{ prefix: "折腾 Linux 的", highlight: "乐趣" },
|
{ prefix: "一个人只拥有此生此世", highlight: "是不够的" },
|
||||||
{ prefix: "搭建博客的", highlight: "心得" },
|
{ prefix: "他还需要一个", highlight: "诗意的世界" },
|
||||||
{ prefix: "学习 Docker 的", highlight: "过程" },
|
{ prefix: "记录", highlight: "思考" },
|
||||||
{ prefix: "抵抗遗忘的", highlight: "文字" },
|
{ prefix: "总结", highlight: "生活" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首屏大字的「设计字号」:手机 / sm / md / lg 四档。
|
||||||
|
* 自适应只会在此基础上**缩小**,不会放大 —— 短句保持原来的视觉分量,
|
||||||
|
* 只有放不下的长句才让步。改这里两处要同步(h1 和下面量尺寸的探针)。
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero() {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const [phraseIndex, setPhraseIndex] = useState(0)
|
const [phraseIndex, setPhraseIndex] = useState(0)
|
||||||
@@ -133,6 +147,66 @@ 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">
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
@@ -145,13 +219,39 @@ export function Hero() {
|
|||||||
{site.tagline}
|
{site.tagline}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 className="font-sans text-[min(10.5vw,3rem)] sm:text-6xl md:text-7xl lg:text-8xl font-bold tracking-tight text-foreground leading-[1.1] mb-8 min-h-[1.2em] whitespace-nowrap">
|
<h1
|
||||||
|
ref={h1Ref}
|
||||||
|
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
|
<span
|
||||||
className="hero-cursor inline-block w-[0.08em] sm:w-[5px] md:w-[6px] lg:w-[7px] h-[0.9em] sm:h-[60px] md:h-[72px] lg:h-[96px] bg-foreground align-middle rounded-sm ml-2 -translate-y-1"
|
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">
|
<blockquote className="max-w-xl mx-auto mb-10">
|
||||||
<p className="text-content-secondary text-base sm:text-lg leading-relaxed italic">
|
<p className="text-content-secondary text-base sm:text-lg leading-relaxed italic">
|
||||||
「我不知道我在想什么,直到我读到我写的东西。」
|
「我不知道我在想什么,直到我读到我写的东西。」
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"updatedAt": "2026-09-15T04:44:09.927Z",
|
"updatedAt": "2026-09-15T04:49:54.266Z",
|
||||||
"source": "weread",
|
"source": "weread",
|
||||||
"books": [
|
"books": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user