From 148021389f1378d87de57f294970ad625f30101f Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 14 Jul 2026 01:00:04 +0700 Subject: [PATCH 01/10] feat(toc): add animated On this page table of contents Render the derived heading data as a fixed 'On this page' rail beside post and series-post articles, with an SVG thread whose accented thumb follows the headings currently in view. The connector curves at indent changes via cubic Beziers (control points offset toward the opposite item so steps read as rounded corners, not diagonals) and spans the full height of the first and last entries. Scroll-spy tracks the visible heading range on rAF-coalesced scroll/resize; geometry is measured after mount and re-measured on reflow, so server and first client render stay identical. Filters to h2/h3, normalizes indent to the shallowest heading, smooth-scrolls with a replaceState hash on click, and respects prefers-reduced-motion. Below xl the fixed gutter collapses to a floating trigger opening the same list in a popover. --- .../toc/components/table-of-contents.tsx | 204 ++++++++++++++++++ src/modules/toc/components/toc-thumb.tsx | 45 ++++ src/modules/toc/toc.hooks.ts | 83 +++++++ src/modules/toc/toc.utils.ts | 89 ++++++++ src/routes/posts/$slug.tsx | 2 + src/routes/series/$slug/$postSlug.tsx | 2 + src/ui/styles/app.css | 30 +++ 7 files changed, 455 insertions(+) create mode 100644 src/modules/toc/components/table-of-contents.tsx create mode 100644 src/modules/toc/components/toc-thumb.tsx create mode 100644 src/modules/toc/toc.hooks.ts create mode 100644 src/modules/toc/toc.utils.ts diff --git a/src/modules/toc/components/table-of-contents.tsx b/src/modules/toc/components/table-of-contents.tsx new file mode 100644 index 0000000..f62f573 --- /dev/null +++ b/src/modules/toc/components/table-of-contents.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { ListIcon } from 'lucide-react'; +import type { MouseEvent } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import type { TableOfContents as TableOfContentsData } from '#/modules/markdown/markdown.types'; +import { useActiveHeadings } from '#/modules/toc/toc.hooks'; +import { + buildThreadPath, + itemPaddingLeft, + normalizeEntries, + threadX, +} from '#/modules/toc/toc.utils'; +import { buttonVariants } from '#/ui/components/core/button'; +import { Popover, PopoverContent, PopoverTrigger } from '#/ui/components/core/popover'; +import { cn } from '#/ui/utils'; + +import { TocThumb, type ThumbRange } from './toc-thumb'; + +/** Measured layout of the rendered list, in px, driving the SVG rail. */ +interface Geometry { + width: number; + height: number; + path: string; + bounds: { top: number; height: number }[]; +} + +interface TableOfContentsProps { + toc: TableOfContentsData; + className?: string; + /** Called after a link scrolls the page — lets the mobile popover close. */ + onNavigate?: () => void; +} + +/** + * The "On this page" navigation: a list of h2/h3 links with a curved SVG rail + * whose accented thumb follows the headings currently in view (see + * {@link useActiveHeadings}). Rendered on its own in the desktop gutter and + * inside the mobile popover by {@link PageTableOfContents}. + * + * The rail needs the list's real pixel layout, which only exists after mount, so + * geometry is measured in an effect and re-measured on reflow — until then just + * the links render, keeping server and first client render identical. + */ +export function TableOfContents({ toc, className, onNavigate }: TableOfContentsProps) { + const entries = useMemo(() => normalizeEntries(toc), [toc]); + const ids = useMemo(() => entries.map((entry) => entry.id), [entries]); + const active = useActiveHeadings(ids); + + const listRef = useRef(null); + const itemRefs = useRef<(HTMLLIElement | null)[]>([]); + const [geometry, setGeometry] = useState(null); + + useEffect(() => { + const list = listRef.current; + if (!list || entries.length === 0) return; + + const measure = () => { + const bounds = entries.map((_, index) => { + const element = itemRefs.current[index]; + return element + ? { top: element.offsetTop, height: element.offsetHeight } + : { top: 0, height: 0 }; + }); + const centers = entries.map((entry, index) => ({ + x: threadX(entry.level), + y: bounds[index].top + bounds[index].height / 2, + })); + // Extend the rail from the first/last centers out to the top and bottom + // edges of those items, so it spans their full height rather than stopping + // at their midpoints. Same x as their item, so these are straight runs. + const first = bounds[0]; + const last = bounds[bounds.length - 1]; + const points = [ + { x: centers[0].x, y: first.top }, + ...centers, + { x: centers[centers.length - 1].x, y: last.top + last.height }, + ]; + setGeometry({ + width: list.offsetWidth, + height: list.offsetHeight, + path: buildThreadPath(points), + bounds, + }); + }; + + measure(); + // Re-measure when the list reflows (fonts loading, breakpoint changes that + // toggle the desktop aside between display:none and flex). + const observer = new ResizeObserver(measure); + observer.observe(list); + return () => observer.disconnect(); + }, [entries]); + + if (entries.length === 0) return null; + + const activeSet = new Set(active); + const thumb = geometry ? thumbRange(entries, activeSet, geometry.bounds) : null; + + const handleClick = (event: MouseEvent, id: string) => { + const target = document.getElementById(id); + if (!target) return; + event.preventDefault(); + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + target.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth', block: 'start' }); + // Shareable hash without flooding history with a back-entry per heading. + history.replaceState(null, '', `#${id}`); + onNavigate?.(); + }; + + return ( + + ); +} + +/** Vertical span covering every active item, or `null` when none are active. */ +function thumbRange( + entries: { id: string }[], + activeSet: Set, + bounds: { top: number; height: number }[], +): ThumbRange | null { + const activeIndexes = entries + .map((entry, index) => (activeSet.has(entry.id) ? index : -1)) + .filter((index) => index !== -1); + if (activeIndexes.length === 0) return null; + const first = bounds[activeIndexes[0]]; + const last = bounds[activeIndexes[activeIndexes.length - 1]]; + return { top: first.top, bottom: last.top + last.height }; +} + +/** + * Responsive shell placed once per article. On wide screens the TOC is fixed in + * the gutter just right of the centered article; below `xl` — where that gutter + * collapses — it becomes a floating "On this page" button opening the same list + * in a popover. Renders nothing when the article has no h2/h3 headings. + */ +export function PageTableOfContents({ toc }: { toc: TableOfContentsData }) { + const [open, setOpen] = useState(false); + const hasEntries = useMemo(() => normalizeEntries(toc).length > 0, [toc]); + if (!hasEntries) return null; + + return ( + <> + + +
+ + + + On this page + + + setOpen(false)} /> + + +
+ + ); +} diff --git a/src/modules/toc/components/toc-thumb.tsx b/src/modules/toc/components/toc-thumb.tsx new file mode 100644 index 0000000..5929340 --- /dev/null +++ b/src/modules/toc/components/toc-thumb.tsx @@ -0,0 +1,45 @@ +/** Vertical span, in px, the highlighted thumb should cover along the rail. */ +export interface ThumbRange { + top: number; + bottom: number; +} + +interface TocThumbProps { + width: number; + height: number; + /** SVG path threading through every TOC item, shared by both layers. */ + path: string; + /** Active span to reveal, or `null` to show only the faint rail. */ + thumb: ThumbRange | null; +} + +/** + * The rail behind the TOC list: the same path drawn twice. The lower layer is a + * faint full-length line; the upper layer is the accented "thumb", identical but + * clipped with an inset `clip-path` to the active vertical span. Animating only + * the clip inset (not the geometry) lets the highlight glide along the shared + * curve as the active range grows, shrinks, and moves — the technique from + * fuma-nama.dev's SVG posts, recolored to this project's monochrome palette. + */ +export function TocThumb({ width, height, path, thumb }: TocThumbProps) { + return ( + + + {thumb && ( + + )} + + ); +} diff --git a/src/modules/toc/toc.hooks.ts b/src/modules/toc/toc.hooks.ts new file mode 100644 index 0000000..eee8ca8 --- /dev/null +++ b/src/modules/toc/toc.hooks.ts @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** Whether two id lists hold the same ids in the same order. */ +function sameOrder(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +/** + * Tracks which headings are currently on screen, in document order, so the rail + * thumb can span the full visible range rather than a single anchor. + * + * A heading's section runs from its own top down to the next heading's top; it + * counts as visible when that band overlaps the viewport. When nothing overlaps + * (mid-scroll between two sections, or above the first heading), it falls back + * to the nearest heading at or above the top so the thumb never disappears. + * + * Reads live layout on scroll/resize (coalesced into an animation frame) instead + * of caching offsets, so it stays correct as images load and reflow the article. + * `ids` must be referentially stable across renders (memoize at the call site) — + * it keys the effect. + */ +export function useActiveHeadings(ids: string[]): string[] { + const [active, setActive] = useState([]); + + useEffect(() => { + if (ids.length === 0) { + setActive([]); + return; + } + + let frame = 0; + + const compute = () => { + frame = 0; + const viewportHeight = window.innerHeight; + const tops = ids.map((id) => { + const element = document.getElementById(id); + return element ? element.getBoundingClientRect().top : Number.POSITIVE_INFINITY; + }); + + const visible: string[] = []; + for (let i = 0; i < ids.length; i++) { + const top = tops[i]; + const nextTop = i + 1 < ids.length ? tops[i + 1] : Number.POSITIVE_INFINITY; + // The section [top, nextTop) overlaps the viewport [0, viewportHeight). + if (top < viewportHeight && nextTop > 0) visible.push(ids[i]); + } + + if (visible.length === 0) { + // Nothing straddles the viewport: highlight the last heading scrolled + // past, or the first one when we're still above it. + let index = 0; + for (let i = 0; i < ids.length; i++) { + if (tops[i] <= 0) index = i; + } + visible.push(ids[index]); + } + + setActive((previous) => (sameOrder(previous, visible) ? previous : visible)); + }; + + const schedule = () => { + if (!frame) frame = requestAnimationFrame(compute); + }; + + compute(); + window.addEventListener('scroll', schedule, { passive: true }); + window.addEventListener('resize', schedule); + return () => { + window.removeEventListener('scroll', schedule); + window.removeEventListener('resize', schedule); + if (frame) cancelAnimationFrame(frame); + }; + }, [ids]); + + return active; +} diff --git a/src/modules/toc/toc.utils.ts b/src/modules/toc/toc.utils.ts new file mode 100644 index 0000000..1eaf3fd --- /dev/null +++ b/src/modules/toc/toc.utils.ts @@ -0,0 +1,89 @@ +import type { TableOfContents } from '#/modules/markdown/markdown.types'; + +/** + * A table-of-contents entry prepared for rendering: the raw heading's `id` and + * `title`, plus a `level` normalized so the shallowest kept heading sits at 0. + * Normalizing (rather than using the raw `depth`) means a document whose + * headings start at `h3` still renders flush-left instead of pre-indented. + */ +export interface TocEntry { + id: string; + title: string; + level: number; +} + +// Only h2/h3 make it into the rail — deeper headings would over-nest the +// narrow gutter, and h1 is the page title, rendered outside the MDX body. +const MIN_DEPTH = 2; +const MAX_DEPTH = 3; + +/** + * Keeps the h2/h3 headings and normalizes their depth to a 0-based `level` + * relative to the shallowest heading present, so indentation is consistent + * regardless of which level a document happens to start at. Returns `[]` when + * nothing qualifies, so callers can render nothing rather than an empty rail. + */ +export function normalizeEntries(toc: TableOfContents): TocEntry[] { + const kept = toc.filter((entry) => entry.depth >= MIN_DEPTH && entry.depth <= MAX_DEPTH); + if (kept.length === 0) return []; + const minDepth = Math.min(...kept.map((entry) => entry.depth)); + return kept.map((entry) => ({ + id: entry.id, + title: entry.title, + level: entry.depth - minDepth, + })); +} + +// Geometry of the SVG rail, in px (its viewBox maps 1:1 to rendered pixels). +// The thread sits in each item's left padding; both step right by INDENT per +// level so the line and the text indent together. +const THREAD_X_BASE = 2; +const ITEM_PADDING_BASE = 16; +const INDENT = 16; + +/** Horizontal position of the rail for a heading at `level`. */ +export function threadX(level: number): number { + return THREAD_X_BASE + level * INDENT; +} + +/** Left padding of a TOC link at `level`, keeping its text clear of the rail. */ +export function itemPaddingLeft(level: number): number { + return ITEM_PADDING_BASE + level * INDENT; +} + +export interface Point { + x: number; + y: number; +} + +// How far, in px, the Bézier control points sit above/below the item they curve +// toward. Kept small relative to the gap so the rail stays vertical through most +// of a stepping segment and only rounds off near each end — the fuma-nama.dev +// connector shape. Capped below half the gap so the control points never cross +// (which would kink the curve) when items sit close together. +const BEND_INSET = 8; + +/** + * Builds the SVG path that threads through each item's point in order. Segments + * between items at the same indent are straight verticals; a step to a different + * indent is a single cubic Bézier whose control points sit near the *opposite* + * item's height — so the rail leaves the previous column heading straight down, + * rounds across in the middle, and arrives at the next column heading straight + * down. Placing the controls at the midpoint instead would draw a straight + * diagonal; offsetting them toward the far ends is what makes it read as a bend. + */ +export function buildThreadPath(points: Point[]): string { + if (points.length === 0) return ''; + let path = `M${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + const previous = points[i - 1]; + const current = points[i]; + if (current.x === previous.x) { + path += ` L${current.x} ${current.y}`; + continue; + } + const inset = Math.min(BEND_INSET, (current.y - previous.y) * 0.4); + path += ` C${previous.x} ${current.y - inset} ${current.x} ${previous.y + inset} ${current.x} ${current.y}`; + } + return path; +} diff --git a/src/routes/posts/$slug.tsx b/src/routes/posts/$slug.tsx index 7d6c18e..19b81dc 100644 --- a/src/routes/posts/$slug.tsx +++ b/src/routes/posts/$slug.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { getPostBySlugFn } from '#/modules/post/post.fn'; import { ThumbnailFigure } from '#/modules/thumbnail/components/thumbnail-figure'; +import { PageTableOfContents } from '#/modules/toc/components/table-of-contents'; import { Badge } from '#/ui/components/core/badge'; import { Separator } from '#/ui/components/core/separator'; @@ -36,6 +37,7 @@ function PostPage() {
{post.mdx}
+ ); } diff --git a/src/routes/series/$slug/$postSlug.tsx b/src/routes/series/$slug/$postSlug.tsx index 12cf8a3..f4ecf40 100644 --- a/src/routes/series/$slug/$postSlug.tsx +++ b/src/routes/series/$slug/$postSlug.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router'; import { getSeriesPostFn } from '#/modules/series/series.fn'; import { ThumbnailFigure } from '#/modules/thumbnail/components/thumbnail-figure'; +import { PageTableOfContents } from '#/modules/toc/components/table-of-contents'; import { Badge } from '#/ui/components/core/badge'; import { Separator } from '#/ui/components/core/separator'; @@ -44,6 +45,7 @@ function SeriesPostPage() {
{post.mdx}
+ ); } diff --git a/src/ui/styles/app.css b/src/ui/styles/app.css index a135d9f..efcd43a 100644 --- a/src/ui/styles/app.css +++ b/src/ui/styles/app.css @@ -132,3 +132,33 @@ @apply font-sans; } } + +/* + * Thin scrollbar for the table-of-contents scroll areas (desktop gutter aside, + * mobile popover), mirroring the code-block/table scrollbar in markdown.css so + * long TOCs scroll without a heavy native bar. Fine pointers only; touch devices + * keep their native overlay scrollbars. + */ +@media (hover: hover) { + .toc-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklab, var(--color-foreground) 20%, transparent) transparent; + } + + .toc-scroll::-webkit-scrollbar { + width: 0.375rem; + } + + .toc-scroll::-webkit-scrollbar-track { + background: transparent; + } + + .toc-scroll::-webkit-scrollbar-thumb { + border-radius: var(--radius-lg); + background-color: color-mix(in oklab, var(--color-foreground) 20%, transparent); + } + + .toc-scroll::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in oklab, var(--color-foreground) 35%, transparent); + } +} From 086692e9a2671fe88be5dd8198cd5b6c644910aa Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 14 Jul 2026 15:18:14 +0700 Subject: [PATCH 02/10] chore(toc): adjust geo shape --- src/modules/toc/toc.hooks.ts | 51 ++++++++++++++++++++++++------------ src/modules/toc/toc.utils.ts | 25 +++++++++--------- src/ui/styles/app.css | 33 ++++++----------------- 3 files changed, 54 insertions(+), 55 deletions(-) diff --git a/src/modules/toc/toc.hooks.ts b/src/modules/toc/toc.hooks.ts index eee8ca8..ac76966 100644 --- a/src/modules/toc/toc.hooks.ts +++ b/src/modules/toc/toc.hooks.ts @@ -15,10 +15,14 @@ function sameOrder(a: string[], b: string[]): boolean { * Tracks which headings are currently on screen, in document order, so the rail * thumb can span the full visible range rather than a single anchor. * - * A heading's section runs from its own top down to the next heading's top; it - * counts as visible when that band overlaps the viewport. When nothing overlaps - * (mid-scroll between two sections, or above the first heading), it falls back - * to the nearest heading at or above the top so the thumb never disappears. + * A heading's section runs from its own top down to the next heading's top — and + * the last section down to the end of the article — so the bands tile the whole + * content region. A heading counts as visible when its band overlaps the active + * band: the viewport, inset at the top by the heading `scroll-margin-top` so a + * heading highlights exactly as navigating to it settles it onto that line. + * Above the first heading or below the article's content nothing overlaps, so the + * result is empty and the thumb hides: the highlight only shows while article + * content is actually on screen. * * Reads live layout on scroll/resize (coalesced into an animation frame) instead * of caching offsets, so it stays correct as images load and reflow the article. @@ -39,29 +43,42 @@ export function useActiveHeadings(ids: string[]): string[] { const compute = () => { frame = 0; const viewportHeight = window.innerHeight; + const firstHeading = document.getElementById(ids[0]); const tops = ids.map((id) => { const element = document.getElementById(id); return element ? element.getBoundingClientRect().top : Number.POSITIVE_INFINITY; }); + // Insets the active band's top edge below the viewport edge, so a heading + // that was just scrolled to (via a TOC link or `#` fragment) reads as + // active immediately instead of the section above it staying highlighted + // a moment longer. A fixed value, not read from the heading's + // `scroll-margin-top` (see markdown.css) — just chosen to clear it with + // room to spare. Falls back to no inset when the first heading isn't in + // the DOM yet, matching the `article` fallback below. + const offset = firstHeading + ? parseFloat(getComputedStyle(firstHeading).scrollMarginTop) * 2 + : 0; + + // Cap the last heading's section at the end of the article so the highlight + // clears once the content scrolls out of view, instead of clinging to the + // final heading while the footer below it is on screen. Falls back to + // +Infinity if the article wrapper isn't found, preserving the old span. + const article = firstHeading?.closest('article'); + const contentBottom = article + ? article.getBoundingClientRect().bottom + : Number.POSITIVE_INFINITY; + const visible: string[] = []; for (let i = 0; i < ids.length; i++) { const top = tops[i]; - const nextTop = i + 1 < ids.length ? tops[i + 1] : Number.POSITIVE_INFINITY; - // The section [top, nextTop) overlaps the viewport [0, viewportHeight). - if (top < viewportHeight && nextTop > 0) visible.push(ids[i]); - } - - if (visible.length === 0) { - // Nothing straddles the viewport: highlight the last heading scrolled - // past, or the first one when we're still above it. - let index = 0; - for (let i = 0; i < ids.length; i++) { - if (tops[i] <= 0) index = i; - } - visible.push(ids[index]); + const nextTop = i + 1 < ids.length ? tops[i + 1] : contentBottom; + // The section [top, nextTop) overlaps the active band [offset, viewportHeight). + if (top < viewportHeight && nextTop > offset) visible.push(ids[i]); } + // No band overlaps: we're above the first heading or past the article's + // content, so nothing is active and the thumb hides. setActive((previous) => (sameOrder(previous, visible) ? previous : visible)); }; diff --git a/src/modules/toc/toc.utils.ts b/src/modules/toc/toc.utils.ts index 1eaf3fd..2b34135 100644 --- a/src/modules/toc/toc.utils.ts +++ b/src/modules/toc/toc.utils.ts @@ -38,8 +38,8 @@ export function normalizeEntries(toc: TableOfContents): TocEntry[] { // The thread sits in each item's left padding; both step right by INDENT per // level so the line and the text indent together. const THREAD_X_BASE = 2; -const ITEM_PADDING_BASE = 16; -const INDENT = 16; +const ITEM_PADDING_BASE = 18; +const INDENT = 12; /** Horizontal position of the rail for a heading at `level`. */ export function threadX(level: number): number { @@ -56,21 +56,20 @@ export interface Point { y: number; } -// How far, in px, the Bézier control points sit above/below the item they curve -// toward. Kept small relative to the gap so the rail stays vertical through most -// of a stepping segment and only rounds off near each end — the fuma-nama.dev -// connector shape. Capped below half the gap so the control points never cross -// (which would kink the curve) when items sit close together. +// How far, in px, the rail runs straight down before and after the diagonal that +// steps between columns. Kept small relative to the gap so the rail stays +// vertical through most of a stepping segment and only angles across near the +// middle. Capped below half the gap so the two vertical runs never overlap (and +// the corners never invert) when items sit close together. const BEND_INSET = 8; /** * Builds the SVG path that threads through each item's point in order. Segments * between items at the same indent are straight verticals; a step to a different - * indent is a single cubic Bézier whose control points sit near the *opposite* - * item's height — so the rail leaves the previous column heading straight down, - * rounds across in the middle, and arrives at the next column heading straight - * down. Placing the controls at the midpoint instead would draw a straight - * diagonal; offsetting them toward the far ends is what makes it read as a bend. + * indent leaves the previous column heading straight down, cuts across on a + * straight diagonal, then arrives at the next column heading straight down. The + * three segments meet at sharp corners — no rounding — so the bend reads as a + * crisp edge rather than a curve. */ export function buildThreadPath(points: Point[]): string { if (points.length === 0) return ''; @@ -83,7 +82,7 @@ export function buildThreadPath(points: Point[]): string { continue; } const inset = Math.min(BEND_INSET, (current.y - previous.y) * 0.4); - path += ` C${previous.x} ${current.y - inset} ${current.x} ${previous.y + inset} ${current.x} ${current.y}`; + path += ` L${previous.x} ${previous.y + inset} L${current.x} ${current.y - inset} L${current.x} ${current.y}`; } return path; } diff --git a/src/ui/styles/app.css b/src/ui/styles/app.css index efcd43a..e82a877 100644 --- a/src/ui/styles/app.css +++ b/src/ui/styles/app.css @@ -134,31 +134,14 @@ } /* - * Thin scrollbar for the table-of-contents scroll areas (desktop gutter aside, - * mobile popover), mirroring the code-block/table scrollbar in markdown.css so - * long TOCs scroll without a heavy native bar. Fine pointers only; touch devices - * keep their native overlay scrollbars. + * Hide the scrollbar on the table-of-contents scroll areas (desktop gutter + * aside, mobile popover) while keeping them scrollable — the SVG rail already + * signals overflow, so a bar would just add visual noise. */ -@media (hover: hover) { - .toc-scroll { - scrollbar-width: thin; - scrollbar-color: color-mix(in oklab, var(--color-foreground) 20%, transparent) transparent; - } - - .toc-scroll::-webkit-scrollbar { - width: 0.375rem; - } - - .toc-scroll::-webkit-scrollbar-track { - background: transparent; - } - - .toc-scroll::-webkit-scrollbar-thumb { - border-radius: var(--radius-lg); - background-color: color-mix(in oklab, var(--color-foreground) 20%, transparent); - } +.toc-scroll { + scrollbar-width: none; +} - .toc-scroll::-webkit-scrollbar-thumb:hover { - background-color: color-mix(in oklab, var(--color-foreground) 35%, transparent); - } +.toc-scroll::-webkit-scrollbar { + display: none; } From d23f27b91d143b4d6d30e019a957deaa693d30ad Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 14 Jul 2026 15:26:28 +0700 Subject: [PATCH 03/10] fix(toc): keep rail thumb straight unless a bend's items are both active Center each bend on the shared edge between items and trim the thumb clip by the bend inset at both ends, so a lone active heading reads as a straight line of uniform height and the bend into the next indent only shows once the headings on both of its sides are active. --- .../toc/components/table-of-contents.tsx | 33 ++++++------ src/modules/toc/toc.utils.ts | 51 ++++++++++--------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/src/modules/toc/components/table-of-contents.tsx b/src/modules/toc/components/table-of-contents.tsx index f62f573..0796b31 100644 --- a/src/modules/toc/components/table-of-contents.tsx +++ b/src/modules/toc/components/table-of-contents.tsx @@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { TableOfContents as TableOfContentsData } from '#/modules/markdown/markdown.types'; import { useActiveHeadings } from '#/modules/toc/toc.hooks'; import { + BEND_INSET, buildThreadPath, itemPaddingLeft, normalizeEntries, @@ -63,24 +64,15 @@ export function TableOfContents({ toc, className, onNavigate }: TableOfContentsP ? { top: element.offsetTop, height: element.offsetHeight } : { top: 0, height: 0 }; }); - const centers = entries.map((entry, index) => ({ + const items = entries.map((entry, index) => ({ x: threadX(entry.level), - y: bounds[index].top + bounds[index].height / 2, + top: bounds[index].top, + bottom: bounds[index].top + bounds[index].height, })); - // Extend the rail from the first/last centers out to the top and bottom - // edges of those items, so it spans their full height rather than stopping - // at their midpoints. Same x as their item, so these are straight runs. - const first = bounds[0]; - const last = bounds[bounds.length - 1]; - const points = [ - { x: centers[0].x, y: first.top }, - ...centers, - { x: centers[centers.length - 1].x, y: last.top + last.height }, - ]; setGeometry({ width: list.offsetWidth, height: list.offsetHeight, - path: buildThreadPath(points), + path: buildThreadPath(items), bounds, }); }; @@ -143,7 +135,15 @@ export function TableOfContents({ toc, className, onNavigate }: TableOfContentsP ); } -/** Vertical span covering every active item, or `null` when none are active. */ +/** + * Vertical span covering every active item, or `null` when none are active. + * The span pulls in by {@link BEND_INSET} at both ends: the rail's bends sit + * within that distance of the edge between two items, so a lone active item + * reads as a straight line and a bend only shows when the items on both of + * its sides are active. Trimming both ends unconditionally — not just beside + * a bend — keeps every thumb the same height and centered on its items + * regardless of the neighbors' indent. + */ function thumbRange( entries: { id: string }[], activeSet: Set, @@ -155,7 +155,10 @@ function thumbRange( if (activeIndexes.length === 0) return null; const first = bounds[activeIndexes[0]]; const last = bounds[activeIndexes[activeIndexes.length - 1]]; - return { top: first.top, bottom: last.top + last.height }; + return { + top: first.top + BEND_INSET, + bottom: last.top + last.height - BEND_INSET, + }; } /** diff --git a/src/modules/toc/toc.utils.ts b/src/modules/toc/toc.utils.ts index 2b34135..f5b9979 100644 --- a/src/modules/toc/toc.utils.ts +++ b/src/modules/toc/toc.utils.ts @@ -51,38 +51,41 @@ export function itemPaddingLeft(level: number): number { return ITEM_PADDING_BASE + level * INDENT; } -export interface Point { +/** One TOC item's slice of the rail: its column `x` and vertical extent, in px. */ +export interface ThreadItem { x: number; - y: number; + top: number; + bottom: number; } -// How far, in px, the rail runs straight down before and after the diagonal that -// steps between columns. Kept small relative to the gap so the rail stays -// vertical through most of a stepping segment and only angles across near the -// middle. Capped below half the gap so the two vertical runs never overlap (and -// the corners never invert) when items sit close together. -const BEND_INSET = 8; +// How far, in px, the diagonal that steps between columns extends above and +// below the shared edge between two items. Keeping the bend this close to the +// edge lets the thumb clip pull in by the same amount to hide a bend into an +// inactive neighbor (see thumbRange in table-of-contents.tsx). Capped so the +// diagonal never reaches past either item's midline when items are short. +export const BEND_INSET = 8; /** - * Builds the SVG path that threads through each item's point in order. Segments - * between items at the same indent are straight verticals; a step to a different - * indent leaves the previous column heading straight down, cuts across on a - * straight diagonal, then arrives at the next column heading straight down. The - * three segments meet at sharp corners — no rounding — so the bend reads as a - * crisp edge rather than a curve. + * Builds the SVG path that threads through each item's vertical extent in + * order. Runs between items at the same indent are straight verticals; a step + * to a different indent stays vertical until just above the items' shared + * edge, cuts across on a straight diagonal centered on that edge, then resumes + * vertical just below it. The segments meet at sharp corners — no rounding — + * so the bend reads as a crisp edge rather than a curve. */ -export function buildThreadPath(points: Point[]): string { - if (points.length === 0) return ''; - let path = `M${points[0].x} ${points[0].y}`; - for (let i = 1; i < points.length; i++) { - const previous = points[i - 1]; - const current = points[i]; - if (current.x === previous.x) { - path += ` L${current.x} ${current.y}`; +export function buildThreadPath(items: ThreadItem[]): string { + if (items.length === 0) return ''; + let path = `M${items[0].x} ${items[0].top}`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const next = items[i + 1]; + if (!next || next.x === item.x) { + path += ` L${item.x} ${item.bottom}`; continue; } - const inset = Math.min(BEND_INSET, (current.y - previous.y) * 0.4); - path += ` L${previous.x} ${previous.y + inset} L${current.x} ${current.y - inset} L${current.x} ${current.y}`; + const edge = (item.bottom + next.top) / 2; + const inset = Math.min(BEND_INSET, (edge - item.top) * 0.4, (next.bottom - edge) * 0.4); + path += ` L${item.x} ${edge - inset} L${next.x} ${edge + inset}`; } return path; } From 8d181f40585a01f6adbca2f39d54efb8844b1525 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 14 Jul 2026 15:41:27 +0700 Subject: [PATCH 04/10] refactor(toc): split module into dedicated component, hook, and type files Extract PageTableOfContents into its own component file, move rail geometry measurement into a useRailGeometry hook, relocate thumbRange next to the other rail math in toc.utils, and consolidate shared interfaces in toc.types. No behavior change. --- .../toc/components/page-table-of-contents.tsx | 55 ++++++ .../toc/components/table-of-contents.tsx | 159 ++---------------- src/modules/toc/components/toc-thumb.tsx | 6 +- src/modules/toc/toc.hooks.ts | 61 ++++++- src/modules/toc/toc.types.ts | 38 +++++ src/modules/toc/toc.utils.ts | 52 +++--- src/routes/posts/$slug.tsx | 2 +- src/routes/series/$slug/$postSlug.tsx | 2 +- 8 files changed, 194 insertions(+), 181 deletions(-) create mode 100644 src/modules/toc/components/page-table-of-contents.tsx create mode 100644 src/modules/toc/toc.types.ts diff --git a/src/modules/toc/components/page-table-of-contents.tsx b/src/modules/toc/components/page-table-of-contents.tsx new file mode 100644 index 0000000..392d2a5 --- /dev/null +++ b/src/modules/toc/components/page-table-of-contents.tsx @@ -0,0 +1,55 @@ +import { ListIcon } from 'lucide-react'; +import * as React from 'react'; + +import type { TableOfContents as TableOfContentsData } from '#/modules/markdown/markdown.types'; +import { normalizeEntries } from '#/modules/toc/toc.utils'; +import { buttonVariants } from '#/ui/components/core/button'; +import { Popover, PopoverContent, PopoverTrigger } from '#/ui/components/core/popover'; +import { cn } from '#/ui/utils'; + +import { TableOfContents } from './table-of-contents'; + +/** + * Responsive shell placed once per article. On wide screens the TOC is fixed in + * the gutter just right of the centered article; below `xl` — where that gutter + * collapses — it becomes a floating "On this page" button opening the same list + * in a popover. Renders nothing when the article has no h2/h3 headings. + */ +export function PageTableOfContents({ toc }: { toc: TableOfContentsData }) { + const [open, setOpen] = React.useState(false); + const hasEntries = React.useMemo(() => normalizeEntries(toc).length > 0, [toc]); + if (!hasEntries) return null; + + return ( + <> + + +
+ + + + On this page + + + setOpen(false)} /> + + +
+ + ); +} diff --git a/src/modules/toc/components/table-of-contents.tsx b/src/modules/toc/components/table-of-contents.tsx index 0796b31..39bdaf4 100644 --- a/src/modules/toc/components/table-of-contents.tsx +++ b/src/modules/toc/components/table-of-contents.tsx @@ -1,31 +1,11 @@ -'use client'; - -import { ListIcon } from 'lucide-react'; -import type { MouseEvent } from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import * as React from 'react'; import type { TableOfContents as TableOfContentsData } from '#/modules/markdown/markdown.types'; -import { useActiveHeadings } from '#/modules/toc/toc.hooks'; -import { - BEND_INSET, - buildThreadPath, - itemPaddingLeft, - normalizeEntries, - threadX, -} from '#/modules/toc/toc.utils'; -import { buttonVariants } from '#/ui/components/core/button'; -import { Popover, PopoverContent, PopoverTrigger } from '#/ui/components/core/popover'; +import { useActiveHeadings, useRailGeometry } from '#/modules/toc/toc.hooks'; +import { itemPaddingLeft, normalizeEntries, thumbRange } from '#/modules/toc/toc.utils'; import { cn } from '#/ui/utils'; -import { TocThumb, type ThumbRange } from './toc-thumb'; - -/** Measured layout of the rendered list, in px, driving the SVG rail. */ -interface Geometry { - width: number; - height: number; - path: string; - bounds: { top: number; height: number }[]; -} +import { TocThumb } from './toc-thumb'; interface TableOfContentsProps { toc: TableOfContentsData; @@ -38,59 +18,20 @@ interface TableOfContentsProps { * The "On this page" navigation: a list of h2/h3 links with a curved SVG rail * whose accented thumb follows the headings currently in view (see * {@link useActiveHeadings}). Rendered on its own in the desktop gutter and - * inside the mobile popover by {@link PageTableOfContents}. - * - * The rail needs the list's real pixel layout, which only exists after mount, so - * geometry is measured in an effect and re-measured on reflow — until then just - * the links render, keeping server and first client render identical. + * inside the mobile popover by `PageTableOfContents`. */ export function TableOfContents({ toc, className, onNavigate }: TableOfContentsProps) { - const entries = useMemo(() => normalizeEntries(toc), [toc]); - const ids = useMemo(() => entries.map((entry) => entry.id), [entries]); + const entries = React.useMemo(() => normalizeEntries(toc), [toc]); + const ids = React.useMemo(() => entries.map((entry) => entry.id), [entries]); const active = useActiveHeadings(ids); - - const listRef = useRef(null); - const itemRefs = useRef<(HTMLLIElement | null)[]>([]); - const [geometry, setGeometry] = useState(null); - - useEffect(() => { - const list = listRef.current; - if (!list || entries.length === 0) return; - - const measure = () => { - const bounds = entries.map((_, index) => { - const element = itemRefs.current[index]; - return element - ? { top: element.offsetTop, height: element.offsetHeight } - : { top: 0, height: 0 }; - }); - const items = entries.map((entry, index) => ({ - x: threadX(entry.level), - top: bounds[index].top, - bottom: bounds[index].top + bounds[index].height, - })); - setGeometry({ - width: list.offsetWidth, - height: list.offsetHeight, - path: buildThreadPath(items), - bounds, - }); - }; - - measure(); - // Re-measure when the list reflows (fonts loading, breakpoint changes that - // toggle the desktop aside between display:none and flex). - const observer = new ResizeObserver(measure); - observer.observe(list); - return () => observer.disconnect(); - }, [entries]); + const { listRef, itemRefs, rail } = useRailGeometry(entries); if (entries.length === 0) return null; const activeSet = new Set(active); - const thumb = geometry ? thumbRange(entries, activeSet, geometry.bounds) : null; + const thumb = rail ? thumbRange(entries, activeSet, rail.bounds) : null; - const handleClick = (event: MouseEvent, id: string) => { + const handleClick = (event: React.MouseEvent, id: string) => { const target = document.getElementById(id); if (!target) return; event.preventDefault(); @@ -103,14 +44,7 @@ export function TableOfContents({ toc, className, onNavigate }: TableOfContentsP return (