Skip to content

Commit 5ed6af3

Browse files
authored
Merge pull request #8 from KYJCASTER/feat/long-form-reading
fix(reading): code review 后的边界 / 竞态 / 阅读体验修正
2 parents dcd5e5b + cef51a5 commit 5ed6af3

5 files changed

Lines changed: 62 additions & 17 deletions

File tree

src/app/globals.css

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -627,24 +627,24 @@ html .shiki, html .shiki span { color: var(--shiki-light); background-color: tra
627627
graceful wrapping for the last line, and overflow protection for any
628628
stray super-long URL or code identifier. The `max-width` only kicks in
629629
when the parent grid track is wider than 68ch — under tablet/mobile the
630-
parent track wins, which is what we want. */
630+
parent track wins, which is what we want. `break-word` (not `anywhere`)
631+
because we only want to break long URLs / identifiers when nothing else
632+
fits, never inside ordinary English words mid-paragraph. */
631633
max-width: 68ch;
632634
text-wrap: pretty;
633635
hanging-punctuation: allow-end;
634636
word-break: normal;
635-
overflow-wrap: anywhere;
637+
overflow-wrap: break-word;
636638
}
637639
/* Headings get balanced wrapping so the last line never carries a single
638640
character or English word alone. */
639641
.prose h1, .prose h2, .prose h3, .prose h4 {
640642
text-wrap: balance;
641643
}
642-
/* CJK metrics: in zh-CN context use proportional alternate widths so
643-
half-width punctuation sits tight against glyphs; the latin paragraphs
644-
inside this scope still render with their own kerning since `palt`
645-
only affects the CJK code points. */
646-
:lang(zh) .prose,
647-
:lang(zh-CN) .prose {
644+
/* CJK metrics: in zh context use proportional alternate widths so half-width
645+
punctuation sits tight against glyphs. `:lang(zh)` already matches zh-CN
646+
per spec, so we don't need a separate selector. */
647+
:lang(zh) .prose {
648648
font-feature-settings: "palt", "ss01", "cv11";
649649
}
650650

src/components/article-shortcuts.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ export function ArticleShortcuts() {
6868
const chapters = getChapters()
6969
if (!chapters.length) return
7070
const cursor = findCursor(chapters)
71-
const next = chapters[Math.min(chapters.length - 1, cursor + 1)]
71+
// No chapter ahead — let `j` quietly do nothing rather than slamming
72+
// back to the current chapter top (which is what Math.min(cursor, last)
73+
// would do).
74+
if (cursor + 1 >= chapters.length) return
75+
const next = chapters[cursor + 1]
7276
if (next) scrollToHeading(next)
7377
return
7478
}
@@ -79,14 +83,21 @@ export function ArticleShortcuts() {
7983
const chapters = getChapters()
8084
if (!chapters.length) return
8185
const cursor = findCursor(chapters)
82-
// If we're already inside chapter N (cursor = N), `k` should jump to
83-
// the *start* of chapter N first, then to N-1 on subsequent presses.
84-
const here = cursor >= 0 ? chapters[cursor] : null
86+
// Three cases:
87+
// cursor === -1 → reader is above the first chapter (in the title
88+
// block). `k` should do nothing rather than push
89+
// them down into chapter 1.
90+
// inside chap N → first press jumps to the *start* of chap N, then
91+
// subsequent presses go N-1, N-2 …
92+
// exactly at N → jump to chap N-1.
93+
if (cursor < 0) return
94+
const here = chapters[cursor]
8595
if (here && here.getBoundingClientRect().top < -8) {
8696
scrollToHeading(here)
8797
return
8898
}
89-
const prev = chapters[Math.max(0, cursor - 1)]
99+
if (cursor === 0) return
100+
const prev = chapters[cursor - 1]
90101
if (prev) scrollToHeading(prev)
91102
return
92103
}

src/components/heading-anchors.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,24 @@ export function HeadingAnchors() {
3131
const onClick = async (ev: MouseEvent) => {
3232
ev.preventDefault()
3333
const url = `${window.location.origin}${window.location.pathname}#${h.id}`
34-
try {
35-
await navigator.clipboard.writeText(url)
34+
const flashCopied = () => {
3635
btn.dataset.copied = "true"
3736
btn.textContent = "✓"
3837
window.setTimeout(() => {
3938
btn.dataset.copied = "false"
4039
btn.textContent = "#"
4140
}, 1400)
41+
}
42+
try {
43+
await navigator.clipboard.writeText(url)
44+
flashCopied()
4245
} catch {
4346
// Some browsers refuse without HTTPS / focus — fall back to
4447
// updating the URL hash so the reader can copy from the address
45-
// bar manually.
48+
// bar manually. Still flash the indicator so they get *some*
49+
// feedback that the click was received.
4650
window.history.replaceState(null, "", `#${h.id}`)
51+
flashCopied()
4752
}
4853
}
4954
btn.addEventListener("click", onClick)

src/components/reading-percent.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,17 @@ export function ReadingPercent({ targetSelector }: { targetSelector: string }) {
4040
update()
4141
window.addEventListener("scroll", onScroll, { passive: true })
4242
window.addEventListener("resize", onScroll)
43+
// Lazy-loaded images / collapsed details elements grow the article body
44+
// after first paint; recompute progress when that happens.
45+
let ro: ResizeObserver | null = null
46+
if (typeof ResizeObserver !== "undefined") {
47+
ro = new ResizeObserver(() => onScroll())
48+
ro.observe(el)
49+
}
4350
return () => {
4451
window.removeEventListener("scroll", onScroll)
4552
window.removeEventListener("resize", onScroll)
53+
ro?.disconnect()
4654
}
4755
}, [targetSelector])
4856

src/components/table-of-contents.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,19 @@ export function TableOfContents({ headings, placement = "desktop" }: TableOfCont
140140
update()
141141
window.addEventListener("scroll", onScroll, { passive: true })
142142
window.addEventListener("resize", onScroll)
143+
// Article body height grows as lazy <img>s load — recompute when it does.
144+
// ResizeObserver beats listening to `window.load` because images can finish
145+
// long after the load event has fired (lazy / fetchpriority='low' images).
146+
let ro: ResizeObserver | null = null
147+
const article = document.getElementById("article-body")
148+
if (article && typeof ResizeObserver !== "undefined") {
149+
ro = new ResizeObserver(() => onScroll())
150+
ro.observe(article)
151+
}
143152
return () => {
144153
window.removeEventListener("scroll", onScroll)
145154
window.removeEventListener("resize", onScroll)
155+
ro?.disconnect()
146156
if (raf) cancelAnimationFrame(raf)
147157
}
148158
}, [chapters])
@@ -179,15 +189,25 @@ export function TableOfContents({ headings, placement = "desktop" }: TableOfCont
179189
<li
180190
key={c.head.id}
181191
ref={(el) => {
192+
// Keep the chapterRefs map in sync with the live DOM. React calls
193+
// the ref with `null` on unmount; without removing entries here
194+
// the map would accumulate forever during HMR / heading changes.
182195
if (el) chapterRefs.current.set(c.head.id, el)
196+
else chapterRefs.current.delete(c.head.id)
183197
}}
184198
className="toc-chapter"
185-
data-state={isActive || subActive ? "active" : "upcoming"}
199+
// NB: data-state is OWNED by the scroll handler in useEffect above.
200+
// Don't drive it from React state — the two would race and the
201+
// already-read chapters would flash back to "upcoming" on every
202+
// active-id change. React only owns the auxiliary data-has-active
203+
// flag below, which controls sub-list expansion.
204+
data-has-active={isActive || subActive ? "true" : "false"}
186205
style={{ ["--chapter-progress" as never]: 0 } as React.CSSProperties}
187206
>
188207
<a
189208
ref={(el) => {
190209
if (el) linkRefs.current.set(c.head.id, el)
210+
else linkRefs.current.delete(c.head.id)
191211
}}
192212
href={`#${c.head.id}`}
193213
className="toc-chapter-link"
@@ -201,6 +221,7 @@ export function TableOfContents({ headings, placement = "desktop" }: TableOfCont
201221
<a
202222
ref={(el) => {
203223
if (el) linkRefs.current.set(s.id, el)
224+
else linkRefs.current.delete(s.id)
204225
}}
205226
href={`#${s.id}`}
206227
className="toc-sub-link"

0 commit comments

Comments
 (0)