Skip to content
This repository was archived by the owner on Jul 7, 2026. It is now read-only.

Commit 6551966

Browse files
ChanMeng666claude
andcommitted
feat(story): per-reader font / line-height prefs and scroll position memory
Story pages now remember how each reader likes to read. Reading preferences - New floating Type button at the bottom-right of the reader opens a popover with three font sizes (sm / md / lg) and three line heights (compact / normal / relaxed) - useReadingPrefs hook persists choices to localStorage via useSyncExternalStore so multiple instances stay in sync within a tab - StoryReader injects the chosen values as CSS custom properties on the <article> wrapper; ChapterBody reads them as fontSize and lineHeight, so the memoized chapter component does not re-render when settings change Reading position memory - useReadingProgress writes a throttled scroll percent (1.5s) to localStorage per story, with floor / ceiling thresholds so trivial and finished reads do not stick - On revisit, ReadingProgressBanner shows a dismissable card with the saved percent and a "回到上次位置" smooth scroll action Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9554785 commit 6551966

6 files changed

Lines changed: 434 additions & 2 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"use client";
2+
3+
import { Type, AlignJustify } from "lucide-react";
4+
import {
5+
Popover,
6+
PopoverContent,
7+
PopoverTrigger,
8+
} from "@/components/ui/popover";
9+
import { Button } from "@/components/ui/button";
10+
import {
11+
useReadingPrefs,
12+
type FontSize,
13+
type LineHeight,
14+
} from "@/lib/hooks/useReadingPrefs";
15+
import { cn } from "@/lib/utils";
16+
17+
const FONT_OPTIONS: Array<{ value: FontSize; label: string; sample: string }> = [
18+
{ value: "sm", label: "小", sample: "A" },
19+
{ value: "md", label: "中", sample: "A" },
20+
{ value: "lg", label: "大", sample: "A" },
21+
];
22+
23+
const LINE_OPTIONS: Array<{ value: LineHeight; label: string }> = [
24+
{ value: "compact", label: "紧凑" },
25+
{ value: "normal", label: "标准" },
26+
{ value: "relaxed", label: "宽松" },
27+
];
28+
29+
const FONT_SAMPLE_CLASS: Record<FontSize, string> = {
30+
sm: "text-xs",
31+
md: "text-sm",
32+
lg: "text-base",
33+
};
34+
35+
export function ReadingPrefs() {
36+
const { fontSize, lineHeight, setFontSize, setLineHeight } = useReadingPrefs();
37+
38+
return (
39+
<Popover>
40+
<PopoverTrigger asChild>
41+
<Button
42+
variant="outline"
43+
size="icon"
44+
aria-label="阅读偏好"
45+
className="fixed bottom-6 right-6 z-30 size-11 rounded-full bg-surface/95 backdrop-blur-lg shadow-lg border-border/60"
46+
>
47+
<Type className="size-4" />
48+
</Button>
49+
</PopoverTrigger>
50+
<PopoverContent
51+
align="end"
52+
side="top"
53+
sideOffset={8}
54+
className="w-64 space-y-4"
55+
>
56+
<div className="space-y-2">
57+
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
58+
<Type className="size-3.5" />
59+
字号
60+
</p>
61+
<div className="grid grid-cols-3 gap-1.5">
62+
{FONT_OPTIONS.map((opt) => (
63+
<button
64+
key={opt.value}
65+
type="button"
66+
onClick={() => setFontSize(opt.value)}
67+
aria-pressed={fontSize === opt.value}
68+
aria-label={`字号 ${opt.label}`}
69+
className={cn(
70+
"h-12 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-colors",
71+
fontSize === opt.value
72+
? "border-primary bg-primary/10 text-primary"
73+
: "border-border bg-surface text-muted-foreground hover:bg-muted"
74+
)}
75+
>
76+
<span className={cn("font-prose", FONT_SAMPLE_CLASS[opt.value])}>
77+
{opt.sample}
78+
</span>
79+
<span className="text-[10px]">{opt.label}</span>
80+
</button>
81+
))}
82+
</div>
83+
</div>
84+
85+
<div className="space-y-2">
86+
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
87+
<AlignJustify className="size-3.5" />
88+
行距
89+
</p>
90+
<div className="grid grid-cols-3 gap-1.5">
91+
{LINE_OPTIONS.map((opt) => (
92+
<button
93+
key={opt.value}
94+
type="button"
95+
onClick={() => setLineHeight(opt.value)}
96+
aria-pressed={lineHeight === opt.value}
97+
aria-label={`行距 ${opt.label}`}
98+
className={cn(
99+
"h-9 rounded-lg border text-xs transition-colors",
100+
lineHeight === opt.value
101+
? "border-primary bg-primary/10 text-primary"
102+
: "border-border bg-surface text-muted-foreground hover:bg-muted"
103+
)}
104+
>
105+
{opt.label}
106+
</button>
107+
))}
108+
</div>
109+
</div>
110+
</PopoverContent>
111+
</Popover>
112+
);
113+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use client";
2+
3+
import { BookmarkCheck, X } from "lucide-react";
4+
import { Button } from "@/components/ui/button";
5+
6+
interface ReadingProgressBannerProps {
7+
percent: number;
8+
onRestore: () => void;
9+
onDismiss: () => void;
10+
}
11+
12+
/**
13+
* Floating restore prompt shown when the user revisits a story they
14+
* scrolled through last time. Sits below the global header and is
15+
* dismissable; the parent decides when to render it.
16+
*/
17+
export function ReadingProgressBanner({
18+
percent,
19+
onRestore,
20+
onDismiss,
21+
}: ReadingProgressBannerProps) {
22+
return (
23+
<div className="fixed top-20 right-4 sm:right-6 z-30 max-w-xs animate-fade-slide-in">
24+
<div className="flex items-start gap-3 rounded-2xl border border-border bg-surface/95 backdrop-blur-lg shadow-lg px-4 py-3">
25+
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/15 text-primary shrink-0">
26+
<BookmarkCheck className="size-4" />
27+
</div>
28+
<div className="flex-1 min-w-0">
29+
<p className="text-sm font-medium text-foreground">
30+
上次读到 {percent}%
31+
</p>
32+
<button
33+
type="button"
34+
onClick={onRestore}
35+
className="mt-1 text-xs font-medium text-primary hover:underline"
36+
>
37+
回到上次位置
38+
</button>
39+
</div>
40+
<Button
41+
variant="ghost"
42+
size="icon"
43+
aria-label="忽略"
44+
onClick={onDismiss}
45+
className="size-7 -mt-1 -mr-1 rounded-full"
46+
>
47+
<X className="size-3.5" />
48+
</Button>
49+
</div>
50+
</div>
51+
);
52+
}

src/components/story/StoryReader.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ import { formatError } from "@/lib/format-error";
1212
import { CommentsSection } from "./CommentsSection";
1313
import { ContinueChapterDialog } from "./ContinueChapterDialog";
1414
import { ViewTracker } from "./ViewTracker";
15+
import { ReadingPrefs } from "./ReadingPrefs";
16+
import { ReadingProgressBanner } from "./ReadingProgressBanner";
17+
import {
18+
useReadingPrefs,
19+
useReadingProgress,
20+
FONT_SIZE_PX,
21+
LINE_HEIGHT_VALUE,
22+
} from "@/lib/hooks";
1523

1624
interface StoryReaderProps {
1725
story: {
@@ -98,7 +106,13 @@ const ChapterBody = memo(function ChapterBody({
98106
{chapterTitle && chapterTitle !== storyTitle ? `:${chapterTitle}` : ""}
99107
</h2>
100108
)}
101-
<div className="font-prose text-foreground/90 leading-8 text-base md:text-lg whitespace-pre-wrap">
109+
<div
110+
className="font-prose text-foreground/90 whitespace-pre-wrap"
111+
style={{
112+
fontSize: "var(--reader-font-size, 1.075rem)",
113+
lineHeight: "var(--reader-line-height, 1.85)",
114+
}}
115+
>
102116
{content}
103117
</div>
104118
{showSeparator && <Separator className="mt-12" />}
@@ -119,6 +133,9 @@ export function StoryReader({
119133
const [liking, setLiking] = useState(false);
120134
const [continueOpen, setContinueOpen] = useState(false);
121135

136+
const { fontSize, lineHeight } = useReadingPrefs();
137+
const { savedPercent, restore, dismiss } = useReadingProgress({ storyId: story.id });
138+
122139
const displayDate = story.publishedAt ?? story.createdAt;
123140
const chapterCount = story.chapters.length;
124141

@@ -141,7 +158,15 @@ export function StoryReader({
141158
}
142159

143160
return (
144-
<article className="max-w-3xl mx-auto px-3 sm:px-4 py-6 sm:py-10">
161+
<article
162+
className="max-w-3xl mx-auto px-3 sm:px-4 py-6 sm:py-10"
163+
style={
164+
{
165+
"--reader-font-size": FONT_SIZE_PX[fontSize],
166+
"--reader-line-height": LINE_HEIGHT_VALUE[lineHeight],
167+
} as React.CSSProperties
168+
}
169+
>
145170
<header className="mb-6 sm:mb-8 space-y-3 sm:space-y-4">
146171
<div className="flex items-start justify-between gap-3">
147172
<h1 className="font-display text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-foreground leading-tight">
@@ -285,6 +310,15 @@ export function StoryReader({
285310
)}
286311

287312
<ViewTracker storyId={story.id} />
313+
314+
<ReadingPrefs />
315+
{savedPercent !== null && (
316+
<ReadingProgressBanner
317+
percent={savedPercent}
318+
onRestore={restore}
319+
onDismiss={dismiss}
320+
/>
321+
)}
288322
</article>
289323
);
290324
}

src/lib/hooks/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,11 @@ export { useStoryCreation } from "./useStoryCreation";
33
export { useMousePositionRef } from "./use-mouse-position-ref";
44
export { useInfiniteScroll } from "./useInfiniteScroll";
55
export { useDebounce } from "./useDebounce";
6+
export {
7+
useReadingPrefs,
8+
FONT_SIZE_PX,
9+
LINE_HEIGHT_VALUE,
10+
type FontSize,
11+
type LineHeight,
12+
} from "./useReadingPrefs";
13+
export { useReadingProgress } from "./useReadingProgress";

src/lib/hooks/useReadingPrefs.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"use client";
2+
3+
import { useCallback, useSyncExternalStore } from "react";
4+
5+
export type FontSize = "sm" | "md" | "lg";
6+
export type LineHeight = "compact" | "normal" | "relaxed";
7+
8+
interface ReadingPrefs {
9+
fontSize: FontSize;
10+
lineHeight: LineHeight;
11+
}
12+
13+
const STORAGE_KEY = "fanfic-lab:reading-prefs";
14+
const DEFAULT_PREFS: ReadingPrefs = { fontSize: "md", lineHeight: "normal" };
15+
16+
// Per-tab pub/sub so multiple <ReadingPrefs /> instances stay in sync
17+
// after the user picks a new value (the native `storage` event only
18+
// fires across tabs, not within the same tab).
19+
const listeners = new Set<() => void>();
20+
let cachedSnapshot: ReadingPrefs | null = null;
21+
22+
function readFromStorage(): ReadingPrefs {
23+
try {
24+
const raw = localStorage.getItem(STORAGE_KEY);
25+
if (!raw) return DEFAULT_PREFS;
26+
const parsed = JSON.parse(raw) as Partial<ReadingPrefs>;
27+
return {
28+
fontSize: parsed.fontSize ?? DEFAULT_PREFS.fontSize,
29+
lineHeight: parsed.lineHeight ?? DEFAULT_PREFS.lineHeight,
30+
};
31+
} catch {
32+
return DEFAULT_PREFS;
33+
}
34+
}
35+
36+
function subscribe(onChange: () => void): () => void {
37+
listeners.add(onChange);
38+
if (typeof window !== "undefined") {
39+
window.addEventListener("storage", onChange);
40+
}
41+
return () => {
42+
listeners.delete(onChange);
43+
if (typeof window !== "undefined") {
44+
window.removeEventListener("storage", onChange);
45+
}
46+
};
47+
}
48+
49+
function getSnapshot(): ReadingPrefs {
50+
// useSyncExternalStore demands a stable reference between renders when
51+
// the underlying value hasn't changed; recompute only after a write.
52+
if (cachedSnapshot === null) cachedSnapshot = readFromStorage();
53+
return cachedSnapshot;
54+
}
55+
56+
function getServerSnapshot(): ReadingPrefs {
57+
return DEFAULT_PREFS;
58+
}
59+
60+
function persist(next: ReadingPrefs) {
61+
cachedSnapshot = next;
62+
try {
63+
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
64+
} catch {
65+
// storage full / blocked — preferences just won't persist
66+
}
67+
listeners.forEach((l) => l());
68+
}
69+
70+
/**
71+
* Lightweight reader preferences hook.
72+
*
73+
* Persists font size and line height to localStorage and returns the
74+
* current values plus setters. SSR-safe via useSyncExternalStore.
75+
*/
76+
export function useReadingPrefs() {
77+
const prefs = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
78+
79+
const setFontSize = useCallback((fontSize: FontSize) => {
80+
persist({ ...getSnapshot(), fontSize });
81+
}, []);
82+
83+
const setLineHeight = useCallback((lineHeight: LineHeight) => {
84+
persist({ ...getSnapshot(), lineHeight });
85+
}, []);
86+
87+
return { ...prefs, setFontSize, setLineHeight };
88+
}
89+
90+
export const FONT_SIZE_PX: Record<FontSize, string> = {
91+
sm: "0.95rem",
92+
md: "1.075rem",
93+
lg: "1.25rem",
94+
};
95+
96+
export const LINE_HEIGHT_VALUE: Record<LineHeight, string> = {
97+
compact: "1.65",
98+
normal: "1.85",
99+
relaxed: "2.1",
100+
};

0 commit comments

Comments
 (0)