@hutusi/amytis 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +16 -0
  4. package/CLAUDE.md +10 -11
  5. package/docs/ARCHITECTURE.md +81 -0
  6. package/docs/DIGITAL_GARDEN.md +1 -1
  7. package/docs/guides/importing-vuepress-books.md +95 -36
  8. package/package.json +1 -1
  9. package/scripts/sync-vuepress-book.ts +277 -66
  10. package/site.config.example.ts +3 -3
  11. package/site.config.ts +3 -3
  12. package/src/app/[slug]/layout.tsx +30 -0
  13. package/src/app/books/[slug]/layout.tsx +24 -0
  14. package/src/app/books/[slug]/page.tsx +18 -2
  15. package/src/app/globals.css +67 -0
  16. package/src/app/page.tsx +6 -0
  17. package/src/app/posts/layout.tsx +20 -0
  18. package/src/app/series/[slug]/page.tsx +33 -9
  19. package/src/components/BookReadingShell.tsx +145 -0
  20. package/src/components/BookSidebar.tsx +0 -0
  21. package/src/components/CuratedSeriesSection.tsx +28 -10
  22. package/src/components/FeaturedStoriesSection.tsx +41 -20
  23. package/src/components/Footer.tsx +1 -1
  24. package/src/components/ImmersiveReader.tsx +130 -0
  25. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  26. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  27. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  28. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  29. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  30. package/src/components/ImmersiveToggleButton.tsx +45 -0
  31. package/src/components/MarkdownRenderer.tsx +31 -0
  32. package/src/components/Navbar.tsx +3 -1
  33. package/src/components/PostReadingShell.tsx +68 -0
  34. package/src/components/ReadingProgressBar.tsx +1 -1
  35. package/src/components/SelectedBooksSection.tsx +27 -8
  36. package/src/hooks/useActiveHeading.ts +35 -13
  37. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  38. package/src/i18n/translations.ts +42 -0
  39. package/src/layouts/BookLayout.tsx +46 -89
  40. package/src/layouts/PostLayout.tsx +154 -115
  41. package/src/lib/immersive-reading-prefs.ts +104 -0
  42. package/src/lib/markdown.ts +18 -11
  43. package/src/lib/scroll-utils.ts +44 -6
  44. package/src/lib/shuffle.ts +15 -1
  45. package/src/lib/sort.ts +15 -0
  46. package/src/lib/urls.ts +5 -0
  47. package/tests/integration/book-index-cta.test.ts +87 -0
  48. package/tests/integration/series-index-cta.test.ts +88 -0
  49. package/tests/integration/sync-vuepress-book.test.ts +205 -2
  50. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  51. package/vercel.json +7 -0
@@ -0,0 +1,143 @@
1
+ 'use client';
2
+
3
+ import { useRef } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import type { PostData, CollectionContext, Heading } from '@/lib/markdown';
7
+ import { useLanguage } from './LanguageProvider';
8
+ import { useImmersiveReading } from './ImmersiveReadingProvider';
9
+ import { useSidebarAutoScroll } from '@/hooks/useSidebarAutoScroll';
10
+ import InlineBookToc from './InlineBookToc';
11
+ import { getPostUrl, getPostUrlInCollection, getSeriesListUrl, getSeriesUrl } from '@/lib/urls';
12
+
13
+ // Dedicated TOC sidebar for the immersive reader on a series post. Visually
14
+ // mirrors BookSidebar's `mode="fill"` shape (clean numbered list, left-
15
+ // border accent on the current item, inlined headings under the current
16
+ // post, footer pointing at the listing page) rather than the page-mode
17
+ // SeriesList card — see PR #95 review feedback.
18
+
19
+ interface ImmersiveSeriesSidebarProps {
20
+ seriesSlug: string;
21
+ seriesTitle: string;
22
+ posts: PostData[];
23
+ /** When the post is in a collection, the sidebar can render in that
24
+ * collection's scope by appending `?collection=<slug>` to the URL. Same
25
+ * resolution logic as SeriesList. */
26
+ collectionContexts?: CollectionContext[];
27
+ currentSlug: string;
28
+ /** h2/h3 headings for the current post — rendered as an inline TOC under
29
+ * the current post's row via the shared InlineBookToc component. */
30
+ headings?: Heading[];
31
+ }
32
+
33
+ export default function ImmersiveSeriesSidebar({
34
+ seriesSlug,
35
+ seriesTitle,
36
+ posts,
37
+ collectionContexts,
38
+ currentSlug,
39
+ headings = [],
40
+ }: ImmersiveSeriesSidebarProps) {
41
+ const { t } = useLanguage();
42
+ const { enabled: immersiveEnabled } = useImmersiveReading();
43
+ const searchParams = useSearchParams();
44
+ const collectionParam = searchParams.get('collection');
45
+ const activeCollection = collectionParam
46
+ ? (collectionContexts ?? []).find(c => c.slug === collectionParam) ?? null
47
+ : null;
48
+
49
+ const effectiveSlug = activeCollection?.slug ?? seriesSlug;
50
+ const effectiveTitle = activeCollection?.title ?? seriesTitle;
51
+ const effectivePosts = activeCollection?.posts ?? posts;
52
+ const isCollectionContext = !!activeCollection;
53
+
54
+ // Collections mix posts from different layout segments (`/posts/[slug]` vs
55
+ // `/[series]/[slug]`). When clicking across that boundary, the
56
+ // ImmersiveReadingProvider remounts with `enabled=false` and the overlay
57
+ // closes. Appending `?immersive=1` lets the destination layout's
58
+ // ImmersiveReadingFlagHandler re-enter the reader and strip the flag.
59
+ const postHref = (post: PostData) => {
60
+ const base = activeCollection
61
+ ? getPostUrlInCollection(post, activeCollection.slug)
62
+ : getPostUrl(post);
63
+ if (!immersiveEnabled) return base;
64
+ const sep = base.includes('?') ? '&' : '?';
65
+ return `${base}${sep}immersive=1`;
66
+ };
67
+
68
+ const currentIndex = effectivePosts.findIndex(p => p.slug === currentSlug);
69
+ const currentItemRef = useRef<HTMLLIElement>(null);
70
+ const sidebarRef = useRef<HTMLElement>(null);
71
+ useSidebarAutoScroll(sidebarRef, currentItemRef, currentSlug);
72
+
73
+ if (effectivePosts.length === 0) return null;
74
+
75
+ return (
76
+ <aside
77
+ ref={sidebarRef}
78
+ className="block w-full h-full overflow-y-auto px-4 py-6 scrollbar-hide hover:scrollbar-thin"
79
+ >
80
+ {/* Header — series / collection label + post count + title link */}
81
+ <div className="mb-6 pb-4 border-b border-muted/10">
82
+ <div className="flex items-center justify-between mb-2">
83
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent">
84
+ {isCollectionContext ? t('collection') : t('series')}
85
+ </span>
86
+ <span className="text-xs font-mono text-muted whitespace-nowrap">
87
+ {currentIndex + 1}/{effectivePosts.length}
88
+ </span>
89
+ </div>
90
+ <Link href={getSeriesUrl(effectiveSlug)} className="group block no-underline">
91
+ <h3 className="font-serif font-bold text-heading text-lg leading-snug group-hover:text-accent transition-colors">
92
+ {effectiveTitle}
93
+ </h3>
94
+ </Link>
95
+ </div>
96
+
97
+ {/* Posts list — flat, current with left-border + accent (same treatment
98
+ as BookSidebar's chapter link). Past posts dimmed less than future
99
+ posts, also matching BookSidebar. */}
100
+ <nav aria-label={isCollectionContext ? t('collection') : t('series')}>
101
+ <ul className="space-y-1">
102
+ {effectivePosts.map((post, index) => {
103
+ const isCurrent = post.slug === currentSlug;
104
+ const isPast = index < currentIndex;
105
+ return (
106
+ <li key={post.slug} ref={isCurrent ? currentItemRef : undefined}>
107
+ <Link
108
+ href={postHref(post)}
109
+ className={`block py-2 px-3 rounded-lg text-sm no-underline transition-all duration-200 ${
110
+ isCurrent
111
+ ? 'bg-accent/10 text-accent font-semibold border-l-2 border-accent'
112
+ : isPast
113
+ ? 'text-foreground/70 hover:text-foreground hover:bg-muted/5'
114
+ : 'text-muted hover:text-foreground hover:bg-muted/5'
115
+ }`}
116
+ aria-current={isCurrent ? 'page' : undefined}
117
+ >
118
+ {post.title}
119
+ </Link>
120
+ {isCurrent && <InlineBookToc headings={headings} />}
121
+ </li>
122
+ );
123
+ })}
124
+ </ul>
125
+ </nav>
126
+
127
+ {/* Footer — points at the series listing (not back to the current
128
+ series detail, which the header already links to). Matches
129
+ BookSidebar's "All Books" footer pattern. */}
130
+ <div className="mt-6 pt-4 border-t border-muted/10">
131
+ <Link
132
+ href={getSeriesListUrl()}
133
+ className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
134
+ >
135
+ {t('all_series')}
136
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
137
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
138
+ </svg>
139
+ </Link>
140
+ </div>
141
+ </aside>
142
+ );
143
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
4
+ import { useLanguage } from '@/components/LanguageProvider';
5
+
6
+ export default function ImmersiveToggleButton() {
7
+ const { enabled, toggle } = useImmersiveReading();
8
+ const { t } = useLanguage();
9
+ // The button is the "enter" affordance; in immersive mode the top bar's
10
+ // exit (✕) is the only way out, so the inline button hides to avoid
11
+ // duplicating the exit and reading "Exit reading mode" next to it. Owning
12
+ // the visibility here means callers (PostLayout's article header, etc.)
13
+ // don't need to gate it with `{!enabled && ...}` separately.
14
+ if (enabled) return null;
15
+ const label = t('immersive_reading');
16
+
17
+ return (
18
+ <button
19
+ type="button"
20
+ onClick={toggle}
21
+ title={label}
22
+ aria-label={label}
23
+ className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-sans text-muted hover:text-accent hover:bg-muted/10 transition-colors border border-transparent hover:border-muted/20 select-none"
24
+ >
25
+ <svg
26
+ width="14"
27
+ height="14"
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ strokeWidth="2"
32
+ strokeLinecap="round"
33
+ strokeLinejoin="round"
34
+ aria-hidden="true"
35
+ >
36
+ <path d="M3 7V5a2 2 0 0 1 2-2h2" />
37
+ <path d="M17 3h2a2 2 0 0 1 2 2v2" />
38
+ <path d="M21 17v2a2 2 0 0 1-2 2h-2" />
39
+ <path d="M7 21H5a2 2 0 0 1-2-2v-2" />
40
+ <path d="M8 12h8" />
41
+ </svg>
42
+ <span className="hidden sm:inline">{label}</span>
43
+ </button>
44
+ );
45
+ }
@@ -31,6 +31,22 @@ import { parseFenceMeta } from '@/lib/shiki';
31
31
  import { isExternalUrl } from '@/lib/urls';
32
32
 
33
33
 
34
+ // Flatten an arbitrary React children tree to its text content. Used by the
35
+ // raw-HTML <mermaid> handler below — react-markdown hands us the mermaid
36
+ // source as a tree of text nodes (possibly nested through whitespace-only
37
+ // wrappers) and the Mermaid component expects a single string.
38
+ function flattenChildrenToText(node: React.ReactNode): string {
39
+ if (node == null || typeof node === 'boolean') return '';
40
+ if (typeof node === 'string') return node;
41
+ if (typeof node === 'number') return String(node);
42
+ if (Array.isArray(node)) return node.map(flattenChildrenToText).join('');
43
+ if (React.isValidElement(node)) {
44
+ const children = (node.props as { children?: React.ReactNode }).children;
45
+ return flattenChildrenToText(children);
46
+ }
47
+ return '';
48
+ }
49
+
34
50
  interface MarkdownRendererProps {
35
51
  content: string;
36
52
  latex?: boolean;
@@ -280,6 +296,21 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
280
296
  globaltoc: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
281
297
  homehero: () => null,
282
298
  chatdemo: () => null,
299
+ // <mermaid>...graph syntax...</mermaid> is the VuePress inline form. We
300
+ // already handle ```mermaid fenced blocks via the `code` renderer above;
301
+ // route the raw-HTML form to the same Mermaid component by flattening
302
+ // the children to a string.
303
+ mermaid: ({ children }: { children?: React.ReactNode }) => (
304
+ <Mermaid chart={flattenChildrenToText(children).trim()} />
305
+ ),
306
+ // <GitHubWrapper>...</GitHubWrapper> wraps GitHub project links / cards
307
+ // in the fenix VuePress book. Pass children through unchanged — they're
308
+ // usually a paragraph or an <a>/<img> the author wants to display.
309
+ githubwrapper: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
310
+ // <words type='span' chapter='/' /> is a VuePress word-count widget that
311
+ // we can't reproduce without the upstream counter. Render nothing — the
312
+ // surrounding prose ("全文合计 X 字") degrades to "全文合计 字".
313
+ words: () => null,
283
314
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
284
315
  } as any;
285
316
 
@@ -91,7 +91,9 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
91
91
  }, [isMenuOpen]);
92
92
 
93
93
  return (
94
- <nav className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 select-none ${
94
+ <nav
95
+ data-site-nav
96
+ className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 select-none ${
95
97
  isScrolled
96
98
  ? 'border-muted/10 bg-background/90 backdrop-blur-md shadow-sm'
97
99
  : 'border-transparent bg-transparent'
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
5
+ import ImmersiveReader from '@/components/ImmersiveReader';
6
+ import ImmersiveSeriesSidebar from '@/components/ImmersiveSeriesSidebar';
7
+ import { getSeriesUrl } from '@/lib/urls';
8
+ import type { CollectionContext, Heading, PostData } from '@/lib/markdown';
9
+
10
+ interface PostReadingShellProps {
11
+ post: { slug: string; title: string; series?: string; headings?: Heading[] };
12
+ seriesSlug?: string;
13
+ seriesTitle?: string;
14
+ seriesPosts?: PostData[];
15
+ collectionContexts?: CollectionContext[];
16
+ /** Slim article subtree to render inside the overlay (header + body + nav).
17
+ * Pre-built in PostLayout so the heavy MarkdownRenderer/RstRenderer is the
18
+ * same ReactElement reference as in `children` — only one of the two ever
19
+ * mounts, so the body renders exactly once. */
20
+ overlayArticle: ReactNode;
21
+ /** Full normal-mode layout subtree (sidebar + article + comments + nav +
22
+ * related etc.). Rendered when immersive is off OR the post isn't in a
23
+ * series. */
24
+ children: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * Post-side analog of BookReadingShell. Branches on the immersive `enabled`
29
+ * flag AND whether the post belongs to a series — without a series there's no
30
+ * meaningful TOC for the overlay sidebar, so the toggle is a no-op and we
31
+ * just render the normal layout.
32
+ */
33
+ export default function PostReadingShell({
34
+ post,
35
+ seriesSlug,
36
+ seriesTitle,
37
+ seriesPosts,
38
+ collectionContexts,
39
+ overlayArticle,
40
+ children,
41
+ }: PostReadingShellProps) {
42
+ const { enabled } = useImmersiveReading();
43
+ const inSeries = !!(seriesSlug && seriesPosts && seriesPosts.length > 0);
44
+
45
+ if (!enabled || !inSeries) {
46
+ return <>{children}</>;
47
+ }
48
+
49
+ return (
50
+ <ImmersiveReader
51
+ rootHref={getSeriesUrl(seriesSlug)}
52
+ rootTitle={seriesTitle ?? seriesSlug}
53
+ currentTitle={post.title}
54
+ sidebar={
55
+ <ImmersiveSeriesSidebar
56
+ seriesSlug={seriesSlug}
57
+ seriesTitle={seriesTitle ?? seriesSlug}
58
+ posts={seriesPosts}
59
+ collectionContexts={collectionContexts}
60
+ currentSlug={post.slug}
61
+ headings={post.headings}
62
+ />
63
+ }
64
+ >
65
+ {overlayArticle}
66
+ </ImmersiveReader>
67
+ );
68
+ }
@@ -12,7 +12,7 @@ export default function ReadingProgressBar() {
12
12
  if (progress <= 0) return null;
13
13
 
14
14
  return (
15
- <div className="fixed top-16 left-0 w-full h-0.5 z-50 bg-muted/10">
15
+ <div data-reading-progress className="fixed top-16 left-0 w-full h-0.5 z-50 bg-muted/10">
16
16
  <div
17
17
  className="h-full bg-accent/70 transition-[width] duration-150 ease-out"
18
18
  style={{ width: `${progress}%` }}
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import Link from 'next/link';
5
5
  import CoverImage from './CoverImage';
6
6
  import HorizontalScroll from './HorizontalScroll';
7
7
  import { useLanguage } from './LanguageProvider';
8
- import { shuffle, shuffleSeeded } from '@/lib/shuffle';
8
+ import { shuffle } from '@/lib/shuffle';
9
+ import { byDateAsc, byDateDesc } from '@/lib/sort';
9
10
  import { getBooksListUrl, getBookUrl, getBookChapterUrl } from '@/lib/urls';
10
11
 
11
12
  export interface BookItem {
@@ -16,19 +17,37 @@ export interface BookItem {
16
17
  authors: string[];
17
18
  chapterCount: number;
18
19
  firstChapter?: string;
20
+ date: string;
19
21
  }
20
22
 
23
+ type BookOrder = 'shuffle' | 'date-desc' | 'date-asc';
24
+
21
25
  interface SelectedBooksSectionProps {
22
26
  books: BookItem[];
23
27
  maxItems?: number;
28
+ order?: BookOrder;
29
+ }
30
+
31
+ function canonicalOrder(books: BookItem[], order: BookOrder): BookItem[] {
32
+ if (order === 'date-desc') return [...books].sort(byDateDesc);
33
+ if (order === 'date-asc') return [...books].sort(byDateAsc);
34
+ // For 'shuffle': SSR-stable canonical order (input is already date-desc from getAllBooks).
35
+ // The post-mount useEffect swaps to a random permutation on the client.
36
+ return books;
24
37
  }
25
38
 
26
- export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBooksSectionProps) {
39
+ export default function SelectedBooksSection({ books, maxItems = 4, order = 'shuffle' }: SelectedBooksSectionProps) {
27
40
  const { t } = useLanguage();
28
- const [displayed, setDisplayed] = useState(() => {
29
- const dailySeed = Math.floor(Date.now() / 86400000);
30
- return shuffleSeeded(books, dailySeed).slice(0, maxItems);
31
- });
41
+ const [displayed, setDisplayed] = useState(() => canonicalOrder(books, order).slice(0, maxItems));
42
+
43
+ // Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
44
+ // post-hydration swap is the intentional client-only behaviour, not a sync issue.
45
+ useEffect(() => {
46
+ if (order === 'shuffle') {
47
+ // eslint-disable-next-line react-hooks/set-state-in-effect
48
+ setDisplayed(shuffle(books).slice(0, maxItems));
49
+ }
50
+ }, [books, maxItems, order]);
32
51
 
33
52
  const handleShuffle = useCallback(() => {
34
53
  setDisplayed(shuffle(books).slice(0, maxItems));
@@ -41,7 +60,7 @@ export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBo
41
60
  <div className="flex items-center justify-between mb-8">
42
61
  <h2 className="text-2xl sm:text-3xl font-serif font-bold text-heading">{t('selected_books')}</h2>
43
62
  <div className="flex items-center gap-4">
44
- {books.length > maxItems && (
63
+ {order === 'shuffle' && books.length > maxItems && (
45
64
  <button
46
65
  onClick={handleShuffle}
47
66
  className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { useState, useEffect } from 'react';
4
4
  import type { Heading } from '@/lib/markdown';
5
- import { useScrollY } from './useScrollY';
5
+ import { getScrollableAncestor } from '@/lib/scroll-utils';
6
+
7
+ const ACTIVATION_LINE_PX = 100;
6
8
 
7
9
  export function useActiveHeading(headings: Heading[], enabled = true): string {
8
10
  const [activeId, setActiveId] = useState('');
9
- const scrollY = useScrollY();
10
11
 
11
12
  useEffect(() => {
12
13
  if (!enabled || headings.length === 0) return;
@@ -14,19 +15,40 @@ export function useActiveHeading(headings: Heading[], enabled = true): string {
14
15
  const elements = headings
15
16
  .map(h => document.getElementById(h.id))
16
17
  .filter(Boolean) as HTMLElement[];
17
-
18
18
  if (elements.length === 0) return;
19
19
 
20
- const scrollPosition = scrollY + 100;
21
- let current = elements[0];
22
- for (const el of elements) {
23
- if (el.offsetTop <= scrollPosition) current = el;
24
- else break;
25
- }
26
-
27
- const rafId = requestAnimationFrame(() => { if (current) setActiveId(current.id); });
28
- return () => cancelAnimationFrame(rafId);
29
- }, [scrollY, headings, enabled]);
20
+ // In immersive reading mode the chapter scrolls inside the overlay's
21
+ // <main>, not the window, so subscribe to whichever ancestor is doing
22
+ // the scrolling. `getScrollableAncestor` returns null for normal pages.
23
+ const container = getScrollableAncestor(elements[0]);
24
+ const target: HTMLElement | Window = container ?? window;
25
+
26
+ let rafId = 0;
27
+ const compute = () => {
28
+ const containerTop = container
29
+ ? container.getBoundingClientRect().top
30
+ : 0;
31
+ let current = elements[0];
32
+ for (const el of elements) {
33
+ const top = el.getBoundingClientRect().top - containerTop;
34
+ if (top <= ACTIVATION_LINE_PX) current = el;
35
+ else break;
36
+ }
37
+ setActiveId(current.id);
38
+ };
39
+
40
+ const onScroll = () => {
41
+ cancelAnimationFrame(rafId);
42
+ rafId = requestAnimationFrame(compute);
43
+ };
44
+
45
+ compute();
46
+ target.addEventListener('scroll', onScroll, { passive: true });
47
+ return () => {
48
+ cancelAnimationFrame(rafId);
49
+ target.removeEventListener('scroll', onScroll);
50
+ };
51
+ }, [enabled, headings]);
30
52
 
31
53
  return activeId;
32
54
  }
@@ -1,18 +1,42 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, type RefObject } from 'react';
3
+ import { useEffect, useRef, type RefObject } from 'react';
4
4
 
5
+ /**
6
+ * Scrolls the current sidebar item into view when `dep` changes.
7
+ *
8
+ * - **First run** (sidebar just mounted — e.g. the reader landed on a page
9
+ * mid-TOC): centres the current item so there's context above and below.
10
+ * - **Subsequent runs** (the reader clicked another sidebar link to
11
+ * navigate): only scrolls if the new current item is out of view, and
12
+ * only enough to bring it on-screen. Crucially, if the target was
13
+ * already visible, the sidebar's scroll position does **not** change —
14
+ * clicking a chapter right in front of you no longer makes the sidebar
15
+ * jump.
16
+ *
17
+ * The previous implementation always hard-centred via `scrollTop = ...`
18
+ * regardless of visibility, which on long book TOCs visibly snapped the
19
+ * sidebar back toward the top whenever the new current chapter was in
20
+ * the upper half. `scrollIntoView({ block: 'nearest' })` handles the
21
+ * "skip if already visible" case natively. The `sidebarRef` first
22
+ * parameter is kept for API stability (all callers still pass it); the
23
+ * new implementation doesn't need it because `scrollIntoView` walks up
24
+ * to the nearest scrollable ancestor on its own.
25
+ */
5
26
  export function useSidebarAutoScroll(
6
- sidebarRef: RefObject<HTMLElement | null>,
27
+ _sidebarRef: RefObject<HTMLElement | null>,
7
28
  itemRef: RefObject<HTMLElement | null>,
8
29
  dep: unknown,
9
30
  ): void {
31
+ const hasRunRef = useRef(false);
10
32
  useEffect(() => {
11
- if (itemRef.current && sidebarRef.current) {
12
- const item = itemRef.current;
13
- const sidebar = sidebarRef.current;
14
- sidebar.scrollTop = item.offsetTop - sidebar.clientHeight / 2 + item.offsetHeight / 2;
15
- }
33
+ const item = itemRef.current;
34
+ if (!item) return;
35
+ item.scrollIntoView({
36
+ block: hasRunRef.current ? 'nearest' : 'center',
37
+ inline: 'nearest',
38
+ });
39
+ hasRunRef.current = true;
16
40
  // refs are stable; only re-run when dep changes
17
41
  // eslint-disable-next-line react-hooks/exhaustive-deps
18
42
  }, [dep]);
@@ -145,6 +145,27 @@ export const translations = {
145
145
  archive_description: "A complete chronological archive of all articles.",
146
146
  tags_description: "Explore topics spanning all articles and flow notes.",
147
147
  posts_description: "Browse all articles.",
148
+ immersive_reading: "Immersive reading",
149
+ exit_reading_mode: "Exit reading mode",
150
+ reading_preferences: "Reading preferences",
151
+ font_size: "Font size",
152
+ reading_theme: "Theme",
153
+ column_width: "Width",
154
+ theme_auto: "Auto",
155
+ theme_light: "Light",
156
+ theme_sepia: "Sepia",
157
+ theme_dark: "Dark",
158
+ size_small: "S",
159
+ size_medium: "M",
160
+ size_large: "L",
161
+ size_xl: "XL",
162
+ width_narrow: "Narrow",
163
+ width_medium: "Medium",
164
+ width_wide: "Wide",
165
+ width_full: "Full",
166
+ collapse_sidebar: "Collapse sidebar",
167
+ expand_sidebar: "Expand sidebar",
168
+ reset_to_defaults: "Reset to defaults",
148
169
  },
149
170
  zh: {
150
171
  home: "首页",
@@ -292,6 +313,27 @@ export const translations = {
292
313
  archive_description: "全部文章的时间轴归档。",
293
314
  tags_description: "浏览全部文章与随笔的主题标签。",
294
315
  posts_description: "浏览全部文章。",
316
+ immersive_reading: "沉浸式阅读",
317
+ exit_reading_mode: "退出阅读模式",
318
+ reading_preferences: "阅读设置",
319
+ font_size: "字号",
320
+ reading_theme: "主题",
321
+ column_width: "宽度",
322
+ theme_auto: "自动",
323
+ theme_light: "浅色",
324
+ theme_sepia: "护眼",
325
+ theme_dark: "深色",
326
+ size_small: "小",
327
+ size_medium: "中",
328
+ size_large: "大",
329
+ size_xl: "特大",
330
+ width_narrow: "窄",
331
+ width_medium: "中",
332
+ width_wide: "宽",
333
+ width_full: "全宽",
334
+ collapse_sidebar: "收起目录",
335
+ expand_sidebar: "展开目录",
336
+ reset_to_defaults: "恢复默认",
295
337
  },
296
338
  };
297
339