Improve markdown TOC scrolling (#349)

Use a better system for scrolling using scrollTo on the
OverlayScrollbars ref. This lets me get the heading as close to the top
of the viewport as possible without the convoluted CSS tricks I was
trying before.
This commit is contained in:
Evan Simkowitz 2024-09-06 15:11:46 -07:00 committed by GitHub
parent f07ac79b0b
commit 9f5ccddad2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 32 additions and 35 deletions

View file

@ -17,7 +17,6 @@
font-size: 14px; font-size: 14px;
overflow-wrap: break-word; overflow-wrap: break-word;
margin-bottom: 10px; margin-bottom: 10px;
--half-contents-height: 10em;
.heading { .heading {
&:first-of-type { &:first-of-type {
@ -26,7 +25,6 @@
color: var(--app-text-color); color: var(--app-text-color);
margin-top: 16px; margin-top: 16px;
margin-bottom: 8px; margin-bottom: 8px;
scroll-margin-block-end: var(--half-contents-height);
} }
strong { strong {

View file

@ -8,13 +8,12 @@ import * as util from "@/util/util";
import { useAtomValueSafe } from "@/util/util"; import { useAtomValueSafe } from "@/util/util";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { Atom } from "jotai"; import { Atom } from "jotai";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import React, { CSSProperties, useCallback, useMemo, useRef } from "react"; import React, { CSSProperties, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc"; import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { useHeight } from "../hook/useHeight";
import "./markdown.less"; import "./markdown.less";
const Link = ({ href, children }: { href: string; children: React.ReactNode }) => { const Link = ({ href, children }: { href: string; children: React.ReactNode }) => {
@ -148,23 +147,23 @@ const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts,
const textAtomValue = useAtomValueSafe(textAtom); const textAtomValue = useAtomValueSafe(textAtom);
const tocRef = useRef<TocItem[]>([]); const tocRef = useRef<TocItem[]>([]);
const showToc = useAtomValueSafe(showTocAtom) ?? false; const showToc = useAtomValueSafe(showTocAtom) ?? false;
const contentsRef = useRef<HTMLDivElement>(null); const contentsOsRef = useRef<OverlayScrollbarsComponentRef>(null);
const contentsHeight = useHeight(contentsRef, 200);
const halfContentsHeight = useMemo(() => { const onTocClick = (data: string) => {
return `${contentsHeight / 2}px`; if (contentsOsRef.current && contentsOsRef.current.osInstance()) {
}, [contentsHeight]); const { viewport } = contentsOsRef.current.osInstance().elements();
const headings = viewport.getElementsByClassName("heading");
const onTocClick = useCallback((data: string) => {
if (contentsRef.current) {
const headings = contentsRef.current.getElementsByClassName("heading");
for (const heading of headings) { for (const heading of headings) {
if (heading.textContent === data) { if (heading.textContent === data) {
heading.scrollIntoView({ inline: "nearest", block: "end" }); const headingBoundingRect = heading.getBoundingClientRect();
const viewportBoundingRect = viewport.getBoundingClientRect();
const headingTop = headingBoundingRect.top - viewportBoundingRect.top;
viewport.scrollBy({ top: headingTop });
break;
} }
} }
} }
}, []); };
const markdownComponents = { const markdownComponents = {
a: Link, a: Link,
@ -180,30 +179,19 @@ const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts,
pre: (props: any) => <CodeBlock {...props} onClickExecute={onClickExecute} />, pre: (props: any) => <CodeBlock {...props} onClickExecute={onClickExecute} />,
}; };
const toc = useMemo(() => { // const toc = useMemo(() => {
if (showToc && tocRef.current.length > 0) { // if (showToc && tocRef.current.length > 0) {
return tocRef.current.map((item) => { // return
return ( // }
<a // }, [showToc, tocRef]);
key={item.href}
className="toc-item"
style={{ "--indent-factor": item.depth } as CSSProperties}
onClick={() => onTocClick(item.value)}
>
{item.value}
</a>
);
});
}
}, [showToc, tocRef]);
text = textAtomValue ?? text; text = textAtomValue ?? text;
return ( return (
<div className={clsx("markdown", className)} style={style} ref={contentsRef}> <div className={clsx("markdown", className)} style={style}>
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
ref={contentsOsRef}
className="content" className="content"
style={{ "--half-contents-height": halfContentsHeight } as CSSProperties}
options={{ scrollbars: { autoHide: "leave" } }} options={{ scrollbars: { autoHide: "leave" } }}
> >
<ReactMarkdown <ReactMarkdown
@ -218,7 +206,18 @@ const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts,
<OverlayScrollbarsComponent className="toc" options={{ scrollbars: { autoHide: "leave" } }}> <OverlayScrollbarsComponent className="toc" options={{ scrollbars: { autoHide: "leave" } }}>
<div className="toc-inner"> <div className="toc-inner">
<h4>Table of Contents</h4> <h4>Table of Contents</h4>
{toc} {tocRef.current.map((item) => {
return (
<a
key={item.href}
className="toc-item"
style={{ "--indent-factor": item.depth } as CSSProperties}
onClick={() => onTocClick(item.value)}
>
{item.value}
</a>
);
})}
</div> </div>
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
)} )}