feat: 修复宽屏下正文+TOC整体偏右、顶栏与TOC错位的问题

- 新增 TOC_BREAKPOINT 常量替代硬编码 1536,通过 define:vars 注入客户端脚本
- MainGridLayout 平移正文/TOC 容器让整体视觉居中,并用 margin 计算修复 Navbar 与 TOC 右边缘的 1rem 错位
- data-has-toc 同时判断 siteConfig.toc.enable 与 headings.length,避免 toc 关闭时仍触发平移
- 新增 adaptive-width-for-fuwari.md 教程文档
This commit is contained in:
2026-06-29 12:33:00 +08:00
parent 3aff09be78
commit 89c7c067aa
5 changed files with 248 additions and 7 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ export const siteConfig: SiteConfig = {
}, },
}, },
toc: { toc: {
enable: true, // Display the table of contents on the right side of the post enable: false, // Display the table of contents on the right side of the post
depth: 3, // Maximum heading depth to show in the table, from 1 to 3 depth: 3, // Maximum heading depth to show in the table, from 1 to 3
}, },
favicon: [ favicon: [
+4
View File
@@ -15,3 +15,7 @@ export const MAIN_PANEL_OVERLAPS_BANNER_HEIGHT = 3.5;
// Page width: rem // Page width: rem
export const PAGE_WIDTH = 75; export const PAGE_WIDTH = 75;
// TOC 显示的最低视口宽度(px),需与 Tailwind 的 2xl 断点保持一致
// 同时 CSS 中 @media (min-width: 1536px) 也要同步修改
export const TOC_BREAKPOINT = 1536;
@@ -0,0 +1,232 @@
---
title: Fuwari 宽屏下正文 自动连带TOC一块居中方案
published: 2026-06-29
description: '让正文与右侧 TOC 作为一个整体在视口中真正居中,并修复顶栏与 TOC 的对齐'
image: ''
tags: [fuwari, css, 布局]
category: '教程'
draft: false
lang: ''
---
## 修复 Fuwari 宽屏下「正文 + TOC」整体偏右、视觉不居中的问题。
## 问题原因
原版 Fuwari 在宽屏(≥1536px,即 Tailwind 的 `2xl` 断点)下的布局结构是:
- 正文容器 `<div class="max-w-[var(--page-width)] mx-auto">` 通过 `mx-auto` 在视口中**居中**(占 75rem
- TOC 容器同样 `mx-auto` 居中,TOC 本体通过 `absolute -right-[var(--toc-width)]` 悬浮在**容器右边缘再向右 `toc-width`** 的位置,也就是落在视口右侧的空白区域
结果是:**正文一直钉在视口正中央不动,TOC 额外塞在右边**,两者作为一个整体重心**偏右**,并不是真正的视觉居中。
本方案的做法是:当页面有 TOC 且视口足够宽时,把正文容器和 TOC 容器**同时向左平移 `toc-width / 2`**,让「正文 + TOC」作为一个整体在视口中真正居中;没有 TOC 的页面保持原样,并在切换页面 / 窗口尺寸变化时带 500ms 缓动过渡。同时修复顶栏(Navbar)与 TOC 右边缘的 1rem 错位。
## 涉及的核心变量
`--page-width` 定义在 `src/constants/constants.ts`
```ts title="src/constants/constants.ts"
// Page width: rem
export const PAGE_WIDTH = 75;
```
`--toc-width` 定义在 `src/styles/variables.styl`,会根据 `--page-width` 自动计算
```styl title="src/styles/variables.styl"
--toc-width: calc((100vw - var(--page-width)) / 2 - 1rem)
```
调整 `PAGE_WIDTH` 即可改变正文宽度,`--toc-width` 会自动跟随,无需手动维护。
## 修改步骤
整个方案由「定义断点常量 + 服务端标记 + 客户端切换 class + CSS 媒体查询」四部分协作完成,对应四处文件改动。
### 1. 定义 TOC 显示断点常量
原版代码里 `1536` 这个数字在 JS 和 CSS 中各写死一次,不便维护。先在 `src/constants/constants.ts` 末尾新增一个常量,让 JS 部分可配置。
```diff title="src/constants/constants.ts" lang="ts"
// Page width: rem
export const PAGE_WIDTH = 75;
+
+// TOC 显示的最低视口宽度(px),需与 Tailwind 的 2xl 断点保持一致
+// 同时 CSS 中 @media (min-width: 1536px) 也要同步修改
+export const TOC_BREAKPOINT = 1536;
```
> 注意:CSS 的 `@media (min-width: ...)` 不支持 `var()`,所以 `@media` 里的 `1536px` 仍然是字面量,改这个常量时需要同步改下面第 4 步里的 `@media`。注释里已写明。
### 2. 给正文 / TOC 容器加 ID,并标记是否有 TOC
打开 `src/layouts/MainGridLayout.astro`,做三处改动:
- 给正文外层容器加 `id="content-center-wrap"`
- 给 `<main>` 加 `data-has-toc` 属性(有标题树时输出空字符串,否则不输出)
- 给 TOC 外层容器加 `id="toc-center-wrap"`
```diff title="src/layouts/MainGridLayout.astro" lang="astro"
<!-- Main content -->
<div class="absolute w-full z-30 pointer-events-none" style={`top: ${mainPanelTop}`}>
<!-- The pointer-events-none here prevent blocking the click event of the TOC -->
- <div class="relative max-w-[var(--page-width)] mx-auto pointer-events-auto">
+ <div id="content-center-wrap" class="relative max-w-[var(--page-width)] mx-auto pointer-events-auto">
<div id="main-grid" class="transition duration-700 w-full left-0 right-0 grid grid-cols-[17.5rem_auto] grid-rows-[auto_1fr_auto] lg:grid-rows-[auto]
mx-auto gap-4 px-0 md:px-4"
>
@@
<SideBar class="mb-4 row-start-2 row-end-3 col-span-2 lg:row-start-1 lg:row-end-2 lg:col-span-1 lg:max-w-[17.5rem] onload-animation" headings={headings}></SideBar>
- <main id="swup-container" class="transition-swup-fade col-span-2 lg:col-span-1 overflow-hidden">
+ <main id="swup-container" class="transition-swup-fade col-span-2 lg:col-span-1 overflow-hidden" data-has-toc={siteConfig.toc.enable && headings.length > 0 ? "" : undefined}>
@@
<!-- The things that should be under the banner, only the TOC for now -->
<div class="absolute w-full z-0 hidden 2xl:block">
- <div class="relative max-w-[var(--page-width)] mx-auto">
+ <div id="toc-center-wrap" class="relative max-w-[var(--page-width)] mx-auto">
```
`data-has-toc` 是给客户端 JS 读取的标记,用来判断当前页面是否需要触发平移。必须同时满足两个条件才标记:
- `siteConfig.toc.enable` 为 `true`(用户在 `src/config.ts` 中开启了 TOC 功能)
- `headings.length > 0`(当前文章有可渲染的标题树)
只要其中一个不满足,就不会标记 `data-has-toc`JS 也就不会给 body 加 `toc-offset` 类,正文保持原版居中行为。
### 3. 客户端根据情况给 body 加 `toc-offset` 类
打开 `src/layouts/Layout.astro`,先在 frontmatter 的 import 里加上 `TOC_BREAKPOINT`
```diff title="src/layouts/Layout.astro" lang="astro"
import {
AUTO_MODE,
BANNER_HEIGHT,
BANNER_HEIGHT_EXTEND,
BANNER_HEIGHT_HOME,
DARK_MODE,
DEFAULT_THEME,
LIGHT_MODE,
PAGE_WIDTH,
+ TOC_BREAKPOINT,
} from "../constants/constants";
```
然后在文件末尾追加一段脚本。注意必须用 `is:inline define:vars` 把常量注入到客户端脚本,否则 `<script>` 里拿不到 TS 模块的值。
```astro title="src/layouts/Layout.astro"
<script is:inline define:vars={{ TOC_BREAKPOINT }}>
function adjustTocLayout() {
const hasToc = !!document.querySelector('[data-has-toc]');
const isWide = window.innerWidth >= TOC_BREAKPOINT;
if (hasToc && isWide) {
document.body.classList.add('toc-offset');
} else {
document.body.classList.remove('toc-offset');
}
}
adjustTocLayout();
window.addEventListener('resize', adjustTocLayout);
if (window.swup) {
window.swup.hooks.on('content:replace', () => requestAnimationFrame(adjustTocLayout));
window.swup.hooks.on('page:view', () => requestAnimationFrame(adjustTocLayout));
} else {
document.addEventListener('swup:enable', () => {
adjustTocLayout();
window.swup.hooks.on('content:replace', () => requestAnimationFrame(adjustTocLayout));
window.swup.hooks.on('page:view', () => requestAnimationFrame(adjustTocLayout));
});
}
</script>
```
逻辑:当前页面有 `data-has-toc` 且视口 ≥ `TOC_BREAKPOINT` 时,给 `<body>` 加 `toc-offset` 类,否则移除。
之所以要监听 Swup 的 `content:replace` / `page:view` 钩子,是因为 Fuwari 用 Swup 做无刷新跳转,`<body>` 不在替换范围内,必须由 JS 在每次页面切换后重新判定。
### 4. 用 CSS 实现平移、过渡与顶栏对齐
回到 `src/layouts/MainGridLayout.astro`,在文件末尾追加 `<style is:global>` 块,仅在 `min-width: 1536px` 时生效。
```astro title="src/layouts/MainGridLayout.astro"
<style is:global>
@media (min-width: 1536px) {
/* 用显式 margin 取代 mx-autoauto 无法参与 transition */
#content-center-wrap,
#toc-center-wrap {
margin-left: calc((100% - var(--page-width)) / 2) !important;
margin-right: calc((100% - var(--page-width)) / 2) !important;
transition: margin 500ms cubic-bezier(0.5, 0, 0.3, 1);
}
/* 有 TOC 时整体向左平移 toc-width / 2,让「正文+TOC」视觉居中 */
body.toc-offset #content-center-wrap,
body.toc-offset #toc-center-wrap {
margin-left: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2) !important;
margin-right: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2) !important;
}
/* 顶栏过渡:max-width / padding / margin 都要参与,否则切换时会跳变 */
#top-row {
transition: max-width 500ms cubic-bezier(0.5, 0, 0.3, 1),
padding 500ms cubic-bezier(0.5, 0, 0.3, 1),
margin 500ms cubic-bezier(0.5, 0, 0.3, 1);
}
#top-row .card-base {
transition: background-color 300ms,
max-width 500ms cubic-bezier(0.5, 0, 0.3, 1);
}
/*
* 顶栏对齐关键:
* - margin-left 与正文容器一致,让顶栏左边对齐正文左边
* - margin-right 比正文容器少 1remmax-width 比正文+TOC 多 1rem
* - 这样顶栏右边 = TOC 右边 + 1rem,抵消 #top-row 的 md:px-4 右 padding 后,
* Navbar 实际右边刚好对齐 TOC 右边
*/
body.toc-offset #top-row {
margin-left: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2) !important;
margin-right: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2 - 1rem) !important;
max-width: calc(var(--page-width) + var(--toc-width) + 1rem) !important;
}
/* Navbar 减去左右各 1rem padding 后的实际宽度 */
body.toc-offset #top-row .card-base {
max-width: calc(var(--page-width) + var(--toc-width) - 1rem) !important;
}
/* 关闭内部网格/侧栏的过渡,避免平移时产生抖动 */
#main-grid {
transition-property: none !important;
}
#sidebar-sticky {
transition-property: none !important;
}
}
</style>
```
要点说明:
- 用 `margin: calc((100% - var(--page-width)) / 2)` 取代 `mx-auto`,是因为 `auto` 无法参与 `transition`,显式 calc 才能让平移有过渡动画
- `body.toc-offset` 下左右 margin 各减 `toc-width / 2`,等于把整个容器向左平移 `toc-width / 2`,使「正文 + TOC」整体居中
- **顶栏对齐**`#top-row` 自带 `md:px-4`(左右各 1rem padding),Navbar 受 padding 限制,如果直接让 `#top-row` 的 max-width = `page-width + toc-width`Navbar 右边会比 TOC 右边少 1rem。所以让 `#top-row` 右边多伸出 1rem`margin-right` 少 1rem、`max-width` 多 1rem),Navbar 减去右 padding 后刚好对齐 TOC 右边
- `#main-grid` / `#sidebar-sticky` 强制关闭 `transition-property`,防止正文内部在平移期间发生位移抖动
## 如何使用
方案默认开启,无需任何配置。只需保证 `src/config.ts` 中 TOC 处于启用状态
```ts title="src/config.ts"
toc: {
enable: true, // Display the table of contents on the right side of the post
depth: 3, // Maximum heading depth to show in the table, from 1 to 3
},
```
配置好后 `pnpm dev` 测试一下,访问任意带标题的文章页,宽屏下「正文 + TOC」会作为一个整体在视口居中,顶栏 Navbar 的左右边缘也会与正文内容、TOC 右边对齐;窗口缩到 1536px 以下或进入无 TOC 的页面时,自动恢复正文正中布局,整个过程带 500ms 缓动。
## 触发条件
| 条件 | 是否触发 `toc-offset` | 表现 |
| --- | --- | --- |
| 有 TOC 且视口 ≥ 1536px | 是 | 正文 + TOC 整体居中,Navbar 对齐 TOC 右边 |
| 有 TOC 但视口 < 1536px | 否 | TOC 隐藏,正文居中 |
| 无 TOC(如首页 / 关于页) | 否 | 正文居中(原版行为) |
如果想调整正文宽度,修改 `src/constants/constants.ts` 的 `PAGE_WIDTH` 即可,`--toc-width` 会自动跟随。
如果想调整 TOC 显示的断点,修改 `src/constants/constants.ts` 的 `TOC_BREAKPOINT`**同时**把第 4 步 CSS 里的 `@media (min-width: 1536px)` 同步改成新值。
+3 -2
View File
@@ -15,6 +15,7 @@ import {
DEFAULT_THEME, DEFAULT_THEME,
LIGHT_MODE, LIGHT_MODE,
PAGE_WIDTH, PAGE_WIDTH,
TOC_BREAKPOINT,
} from "../constants/constants"; } from "../constants/constants";
import { defaultFavicons } from "../constants/icon"; import { defaultFavicons } from "../constants/icon";
import type { Favicon } from "../types/config"; import type { Favicon } from "../types/config";
@@ -572,10 +573,10 @@ if (window.swup) {
} }
</script> </script>
<script> <script is:inline define:vars={{ TOC_BREAKPOINT }}>
function adjustTocLayout() { function adjustTocLayout() {
const hasToc = !!document.querySelector('[data-has-toc]'); const hasToc = !!document.querySelector('[data-has-toc]');
const isWide = window.innerWidth >= 1536; const isWide = window.innerWidth >= TOC_BREAKPOINT;
if (hasToc && isWide) { if (hasToc && isWide) {
document.body.classList.add('toc-offset'); document.body.classList.add('toc-offset');
} else { } else {
+8 -4
View File
@@ -82,7 +82,7 @@ const mainPanelTop = siteConfig.banner.enable
<SideBar class="mb-4 row-start-2 row-end-3 col-span-2 lg:row-start-1 lg:row-end-2 lg:col-span-1 lg:max-w-[17.5rem] onload-animation" headings={headings}></SideBar> <SideBar class="mb-4 row-start-2 row-end-3 col-span-2 lg:row-start-1 lg:row-end-2 lg:col-span-1 lg:max-w-[17.5rem] onload-animation" headings={headings}></SideBar>
<main id="swup-container" class="transition-swup-fade col-span-2 lg:col-span-1 overflow-hidden" data-has-toc={headings.length > 0 ? "" : undefined}> <main id="swup-container" class="transition-swup-fade col-span-2 lg:col-span-1 overflow-hidden" data-has-toc={siteConfig.toc.enable && headings.length > 0 ? "" : undefined}>
<div id="content-wrapper" class="onload-animation"> <div id="content-wrapper" class="onload-animation">
<!-- the overflow-hidden here prevent long text break the layout--> <!-- the overflow-hidden here prevent long text break the layout-->
<!-- make id different from windows.swup global property --> <!-- make id different from windows.swup global property -->
@@ -140,7 +140,8 @@ const mainPanelTop = siteConfig.banner.enable
} }
#top-row { #top-row {
transition: max-width 500ms cubic-bezier(0.5, 0, 0.3, 1), transition: max-width 500ms cubic-bezier(0.5, 0, 0.3, 1),
padding 500ms cubic-bezier(0.5, 0, 0.3, 1); padding 500ms cubic-bezier(0.5, 0, 0.3, 1),
margin 500ms cubic-bezier(0.5, 0, 0.3, 1);
} }
#top-row .card-base { #top-row .card-base {
transition: background-color 300ms, transition: background-color 300ms,
@@ -152,10 +153,13 @@ const mainPanelTop = siteConfig.banner.enable
margin-right: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2) !important; margin-right: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2) !important;
} }
body.toc-offset #top-row { body.toc-offset #top-row {
max-width: calc(var(--page-width) + var(--toc-width)) !important; /* 用显式 margin 取代 mx-auto:左边对齐正文内容、右边比 TOC 多 1rem 抵消 padding,让 navbar 右边对齐 TOC 右边 */
margin-left: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2) !important;
margin-right: calc((100% - var(--page-width)) / 2 - var(--toc-width) / 2 - 1rem) !important;
max-width: calc(var(--page-width) + var(--toc-width) + 1rem) !important;
} }
body.toc-offset #top-row .card-base { body.toc-offset #top-row .card-base {
max-width: calc(var(--page-width) + var(--toc-width)) !important; max-width: calc(var(--page-width) + var(--toc-width) - 1rem) !important;
} }
} }
</style> </style>