feat(markdown): render and navigate GFM footnotes
Footnote definitions previously vanished on display. Two underlying issues: - rehype-sanitize re-clobbered `id` attributes with a second `user-content-` prefix while leaving hrefs untouched, so footnote refs/backrefs pointed at ids that didn't exist. Disable clobbering (ids are already namespaced by remark-rehype) so anchors match their targets. - Footnote anchors went through the external Link (target="_blank"), opening a blank tab instead of navigating. Route in-page `#` anchors through a new AnchorLink: scroll within the memo when shown in full, else navigate to the memo detail page with the hash, where MemoDetail scrolls it into view. Also style the footnotes section GitHub-style: thin separator, smaller muted text, and un-underlined ref/backref links.
This commit is contained in:
parent
3b601b8416
commit
10200606db
8 changed files with 108 additions and 9 deletions
|
|
@ -19,7 +19,7 @@ import { CodeBlock } from "./CodeBlock";
|
|||
import { SANITIZE_SCHEMA } from "./constants";
|
||||
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
|
||||
import { Mention } from "./Mention";
|
||||
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
|
||||
import { AnchorLink, Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
|
||||
import { Tag } from "./Tag";
|
||||
import { TaskListItem } from "./TaskListItem";
|
||||
|
|
@ -28,6 +28,10 @@ import { TrustedIframe } from "./TrustedIframe";
|
|||
interface MemoMarkdownRendererProps {
|
||||
content: string;
|
||||
resolvedMentionUsernames: Set<string>;
|
||||
/** Resource name of the memo (e.g. `memos/abc123`), used to target footnote links at the detail page. */
|
||||
memoName?: string;
|
||||
/** Whether the memo is rendered as a collapsed feed card. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function getMentionUsername(node: Element, children?: React.ReactNode): string {
|
||||
|
|
@ -49,7 +53,7 @@ function getMentionUsername(node: Element, children?: React.ReactNode): string {
|
|||
return "";
|
||||
}
|
||||
|
||||
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: MemoMarkdownRendererProps) => {
|
||||
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoName, compact }: MemoMarkdownRendererProps) => {
|
||||
const markdownComponents: Components = {
|
||||
input: ({ node, ...inputProps }) => {
|
||||
if (node && isTaskListItemElement(node)) {
|
||||
|
|
@ -107,7 +111,22 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: Memo
|
|||
</List>
|
||||
),
|
||||
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
|
||||
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
|
||||
a: ({ children, href, ...props }) => {
|
||||
// In-page anchors (footnote refs/backrefs, heading links) navigate within the memo rather
|
||||
// than opening a new tab; everything else is treated as an external link.
|
||||
if (typeof href === "string" && href.startsWith("#")) {
|
||||
return (
|
||||
<AnchorLink href={href} memoName={memoName} compact={compact} {...props}>
|
||||
{children}
|
||||
</AnchorLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
|
||||
iframe: TrustedIframe,
|
||||
img: (props) => <Image {...props} />,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_P
|
|||
*/
|
||||
export const SANITIZE_SCHEMA = {
|
||||
...defaultSchema,
|
||||
// Don't re-prefix `id`/`name` attributes. remark-rehype already namespaces footnote ids with
|
||||
// `user-content-` and emits matching `#user-content-…` hrefs; the default schema's clobbering
|
||||
// would prepend a *second* `user-content-` to the ids only (not the hrefs), breaking in-page
|
||||
// navigation. Leaving ids untouched keeps footnote (and heading) anchors pointing at real targets.
|
||||
clobber: [],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [...(defaultSchema.attributes?.img || []), "height", "width"],
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ const MemoContent = (props: MemoContentProps) => {
|
|||
"[&_.katex-display]:max-w-full",
|
||||
"[&_.katex-display]:overflow-x-auto",
|
||||
"[&_.katex-display]:overflow-y-hidden",
|
||||
// Footnotes: quiet GitHub-style footer — thin separator, smaller muted text, unobtrusive links.
|
||||
"[&_.footnotes]:mt-4 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes]:pt-2",
|
||||
"[&_.footnotes]:text-sm [&_.footnotes]:text-muted-foreground",
|
||||
// GitHub renders footnote ref/backref links without an underline (underline on hover only).
|
||||
"[&_[data-footnote-ref]]:no-underline [&_[data-footnote-ref]:hover]:underline",
|
||||
"[&_.data-footnote-backref]:no-underline [&_.data-footnote-backref:hover]:underline",
|
||||
showCompactMode === "ALL" && "overflow-hidden",
|
||||
contentClassName,
|
||||
)}
|
||||
|
|
@ -40,7 +46,12 @@ const MemoContent = (props: MemoContentProps) => {
|
|||
onMouseUp={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<MemoMarkdownRenderer content={content} resolvedMentionUsernames={resolvedMentionUsernames} />
|
||||
<MemoMarkdownRenderer
|
||||
content={content}
|
||||
resolvedMentionUsernames={resolvedMentionUsernames}
|
||||
memoName={props.memoName}
|
||||
compact={Boolean(props.compact)}
|
||||
/>
|
||||
{showCompactMode === "ALL" && (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
54
web/src/components/MemoContent/markdown/AnchorLink.tsx
Normal file
54
web/src/components/MemoContent/markdown/AnchorLink.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface AnchorLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
|
||||
href: string;
|
||||
/** Resource name of the enclosing memo (e.g. `memos/abc123`), when known. */
|
||||
memoName?: string;
|
||||
/** Whether the memo is rendered as a collapsed feed card. */
|
||||
compact?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders in-page anchors (footnote references/backrefs, heading links — any `href="#…"`).
|
||||
*
|
||||
* When the target lives in a fully-rendered memo (detail, share, preview, expanded feed card),
|
||||
* we scroll to it within that memo's own container — scoped so duplicate footnote ids across a
|
||||
* feed can't send us to the wrong memo. When the memo is a collapsed feed card the footnote is
|
||||
* below the fold, so we fall back to navigating to the memo detail page (with the hash), where
|
||||
* MemoDetail scrolls the target into view.
|
||||
*/
|
||||
export const AnchorLink = ({ href, memoName, compact, children, className, node: _node, ...props }: AnchorLinkProps) => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (compact) return; // Let the link navigate to the detail page.
|
||||
const id = decodeURIComponent(href.slice(1));
|
||||
if (!id) return;
|
||||
// Scope the lookup to this memo's own container so duplicate footnote ids elsewhere in a feed
|
||||
// can't steal the scroll.
|
||||
const root = event.currentTarget.closest("[data-memo-content]");
|
||||
const target = root?.querySelector(`#${CSS.escape(id)}`);
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
};
|
||||
|
||||
const classes = cn(markdownStyles.link, className);
|
||||
|
||||
if (memoName) {
|
||||
return (
|
||||
<Link to={`/${memoName}${href}`} onClick={handleClick} className={classes} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} onClick={handleClick} className={classes} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { AnchorLink } from "./AnchorLink";
|
||||
export { Blockquote } from "./Blockquote";
|
||||
export { Heading } from "./Heading";
|
||||
export { HorizontalRule } from "./HorizontalRule";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import type React from "react";
|
|||
|
||||
export interface MemoContentProps {
|
||||
content: string;
|
||||
/** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */
|
||||
memoName?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
|
|||
>
|
||||
<MemoContent
|
||||
key={memo.name}
|
||||
memoName={memo.name}
|
||||
content={memo.content}
|
||||
onClick={handleMemoContentClick}
|
||||
onDoubleClick={handleMemoContentDoubleClick}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Code, ConnectError } from "@connectrpc/connect";
|
||||
import { ArrowUpLeftFromCircleIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, Navigate, useLocation, useParams } from "react-router-dom";
|
||||
import MemoCommentSection from "@/components/MemoCommentSection";
|
||||
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
|
||||
|
|
@ -57,11 +57,17 @@ const MemoDetail = () => {
|
|||
enabled: !!memo,
|
||||
});
|
||||
|
||||
// Scroll to the hash target once it's in the DOM. The effect re-runs as the memo loads (footnote
|
||||
// anchors) and as comments arrive (comment anchors), since the target may render in either; the
|
||||
// ref guards against re-scrolling the same hash on every later comments page-load.
|
||||
const scrolledHashRef = useRef("");
|
||||
useEffect(() => {
|
||||
if (!hash || comments.length === 0) return;
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [hash, comments]);
|
||||
if (!hash || scrolledHashRef.current === hash) return;
|
||||
const el = document.getElementById(decodeURIComponent(hash.slice(1)));
|
||||
if (!el) return;
|
||||
scrolledHashRef.current = hash;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [hash, memo, comments]);
|
||||
|
||||
if (isShareMode) {
|
||||
const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated);
|
||||
|
|
|
|||
Loading…
Reference in a new issue