From 10200606db24e3d70fb8efefee99c7b0a369ddea Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 29 Jun 2026 22:46:41 +0800 Subject: [PATCH] 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. --- .../MemoContent/MemoMarkdownRenderer.tsx | 25 +++++++-- web/src/components/MemoContent/constants.ts | 5 ++ web/src/components/MemoContent/index.tsx | 13 ++++- .../MemoContent/markdown/AnchorLink.tsx | 54 +++++++++++++++++++ .../components/MemoContent/markdown/index.ts | 1 + web/src/components/MemoContent/types.ts | 2 + .../MemoView/components/MemoBody.tsx | 1 + web/src/pages/MemoDetail.tsx | 16 ++++-- 8 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 web/src/components/MemoContent/markdown/AnchorLink.tsx diff --git a/web/src/components/MemoContent/MemoMarkdownRenderer.tsx b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx index b82912d79..584998a42 100644 --- a/web/src/components/MemoContent/MemoMarkdownRenderer.tsx +++ b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx @@ -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; + /** 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 ), li: ({ children, ...props }) => {children}, - a: ({ children, ...props }) => {children}, + 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 ( + + {children} + + ); + } + return ( + + {children} + + ); + }, code: ({ children, ...props }) => {children}, iframe: TrustedIframe, img: (props) => , diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts index 79c1e5beb..034a25442 100644 --- a/web/src/components/MemoContent/constants.ts +++ b/web/src/components/MemoContent/constants.ts @@ -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"], diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 79c3a59e3..2ba29a4c2 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -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} > - + {showCompactMode === "ALL" && (
, 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) => { + 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 ( + + {children} + + ); + } + + return ( + + {children} + + ); +}; diff --git a/web/src/components/MemoContent/markdown/index.ts b/web/src/components/MemoContent/markdown/index.ts index e395d51eb..a7f1ecdb3 100644 --- a/web/src/components/MemoContent/markdown/index.ts +++ b/web/src/components/MemoContent/markdown/index.ts @@ -1,3 +1,4 @@ +export { AnchorLink } from "./AnchorLink"; export { Blockquote } from "./Blockquote"; export { Heading } from "./Heading"; export { HorizontalRule } from "./HorizontalRule"; diff --git a/web/src/components/MemoContent/types.ts b/web/src/components/MemoContent/types.ts index ed46b1cfe..9def47b15 100644 --- a/web/src/components/MemoContent/types.ts +++ b/web/src/components/MemoContent/types.ts @@ -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; diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index 29ddfa113..cbed5504a 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -36,6 +36,7 @@ const MemoBody: React.FC = ({ compact }) => { > { 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);