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 { SANITIZE_SCHEMA } from "./constants";
|
||||||
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
|
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
|
||||||
import { Mention } from "./Mention";
|
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 { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
|
||||||
import { Tag } from "./Tag";
|
import { Tag } from "./Tag";
|
||||||
import { TaskListItem } from "./TaskListItem";
|
import { TaskListItem } from "./TaskListItem";
|
||||||
|
|
@ -28,6 +28,10 @@ import { TrustedIframe } from "./TrustedIframe";
|
||||||
interface MemoMarkdownRendererProps {
|
interface MemoMarkdownRendererProps {
|
||||||
content: string;
|
content: string;
|
||||||
resolvedMentionUsernames: Set<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 {
|
function getMentionUsername(node: Element, children?: React.ReactNode): string {
|
||||||
|
|
@ -49,7 +53,7 @@ function getMentionUsername(node: Element, children?: React.ReactNode): string {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: MemoMarkdownRendererProps) => {
|
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoName, compact }: MemoMarkdownRendererProps) => {
|
||||||
const markdownComponents: Components = {
|
const markdownComponents: Components = {
|
||||||
input: ({ node, ...inputProps }) => {
|
input: ({ node, ...inputProps }) => {
|
||||||
if (node && isTaskListItemElement(node)) {
|
if (node && isTaskListItemElement(node)) {
|
||||||
|
|
@ -107,7 +111,22 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: Memo
|
||||||
</List>
|
</List>
|
||||||
),
|
),
|
||||||
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
|
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>,
|
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
|
||||||
iframe: TrustedIframe,
|
iframe: TrustedIframe,
|
||||||
img: (props) => <Image {...props} />,
|
img: (props) => <Image {...props} />,
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_P
|
||||||
*/
|
*/
|
||||||
export const SANITIZE_SCHEMA = {
|
export const SANITIZE_SCHEMA = {
|
||||||
...defaultSchema,
|
...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: {
|
attributes: {
|
||||||
...defaultSchema.attributes,
|
...defaultSchema.attributes,
|
||||||
img: [...(defaultSchema.attributes?.img || []), "height", "width"],
|
img: [...(defaultSchema.attributes?.img || []), "height", "width"],
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ const MemoContent = (props: MemoContentProps) => {
|
||||||
"[&_.katex-display]:max-w-full",
|
"[&_.katex-display]:max-w-full",
|
||||||
"[&_.katex-display]:overflow-x-auto",
|
"[&_.katex-display]:overflow-x-auto",
|
||||||
"[&_.katex-display]:overflow-y-hidden",
|
"[&_.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",
|
showCompactMode === "ALL" && "overflow-hidden",
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
|
|
@ -40,7 +46,12 @@ const MemoContent = (props: MemoContentProps) => {
|
||||||
onMouseUp={onClick}
|
onMouseUp={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
<MemoMarkdownRenderer content={content} resolvedMentionUsernames={resolvedMentionUsernames} />
|
<MemoMarkdownRenderer
|
||||||
|
content={content}
|
||||||
|
resolvedMentionUsernames={resolvedMentionUsernames}
|
||||||
|
memoName={props.memoName}
|
||||||
|
compact={Boolean(props.compact)}
|
||||||
|
/>
|
||||||
{showCompactMode === "ALL" && (
|
{showCompactMode === "ALL" && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 { Blockquote } from "./Blockquote";
|
||||||
export { Heading } from "./Heading";
|
export { Heading } from "./Heading";
|
||||||
export { HorizontalRule } from "./HorizontalRule";
|
export { HorizontalRule } from "./HorizontalRule";
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import type React from "react";
|
||||||
|
|
||||||
export interface MemoContentProps {
|
export interface MemoContentProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */
|
||||||
|
memoName?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
|
||||||
>
|
>
|
||||||
<MemoContent
|
<MemoContent
|
||||||
key={memo.name}
|
key={memo.name}
|
||||||
|
memoName={memo.name}
|
||||||
content={memo.content}
|
content={memo.content}
|
||||||
onClick={handleMemoContentClick}
|
onClick={handleMemoContentClick}
|
||||||
onDoubleClick={handleMemoContentDoubleClick}
|
onDoubleClick={handleMemoContentDoubleClick}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Code, ConnectError } from "@connectrpc/connect";
|
import { Code, ConnectError } from "@connectrpc/connect";
|
||||||
import { ArrowUpLeftFromCircleIcon } from "lucide-react";
|
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 { Link, Navigate, useLocation, useParams } from "react-router-dom";
|
||||||
import MemoCommentSection from "@/components/MemoCommentSection";
|
import MemoCommentSection from "@/components/MemoCommentSection";
|
||||||
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
|
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
|
||||||
|
|
@ -57,11 +57,17 @@ const MemoDetail = () => {
|
||||||
enabled: !!memo,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!hash || comments.length === 0) return;
|
if (!hash || scrolledHashRef.current === hash) return;
|
||||||
const el = document.getElementById(hash.slice(1));
|
const el = document.getElementById(decodeURIComponent(hash.slice(1)));
|
||||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
if (!el) return;
|
||||||
}, [hash, comments]);
|
scrolledHashRef.current = hash;
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, [hash, memo, comments]);
|
||||||
|
|
||||||
if (isShareMode) {
|
if (isShareMode) {
|
||||||
const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated);
|
const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue