feat: add outline navigation to memo detail sidebar

Extract h1-h4 headings from memo markdown and display them as an
outline section at the top of the MemoDetail sidebar. Each heading
links to its anchor via smooth scroll with hash URL updates.

- Add slugify() and extractHeadings() utilities for MDAST parsing
- Add rehypeHeadingId plugin to assign unique slug IDs to headings
- Create MemoOutline component with level-based indentation
- Integrate outline as first section in MemoDetailSidebar

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
memoclaw 2026-03-23 23:43:27 +08:00
parent 2aaca27bd5
commit 26d449d7a5
9 changed files with 251 additions and 4 deletions

View File

@ -0,0 +1,35 @@
## Background & Context
The MemoDetail page (`web/src/pages/MemoDetail.tsx`) renders a memo's full content with a right sidebar (`MemoDetailSidebar`) on desktop. The sidebar currently shows share controls, timestamps, property badges (links, to-do, code), and tags. Memo content is rendered via `react-markdown` with custom heading components (h1h6) in `MemoContent/markdown/Heading.tsx`. The page already has hash-based scroll-to-element infrastructure for comments (lines 3943 of MemoDetail.tsx).
## Issue Statement
When viewing a memo with structured headings, there is no outline or table of contents in the MemoDetail sidebar, so readers cannot see the document structure at a glance or jump to specific sections. Heading elements rendered by the `Heading` component do not have `id` attributes, preventing any anchor-based navigation to them.
## Current State
- **`web/src/pages/MemoDetail.tsx`** (93 lines) — MemoDetail page. Hash-based `scrollIntoView` logic exists at lines 3943 but only targets comment elements. The sidebar is rendered at lines 8286 as `<MemoDetailSidebar>`.
- **`web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx`** (109 lines) — Desktop sidebar. Contains sections: Share (lines 5865), Created-at (lines 6776), Properties (lines 7889), Tags (lines 91102). Uses a reusable `SidebarSection` helper (lines 2432). No outline section exists.
- **`web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx`** (36 lines) — Mobile drawer wrapper for the sidebar.
- **`web/src/components/MemoContent/markdown/Heading.tsx`** (30 lines) — Renders h1h6 with Tailwind classes. Does not generate `id` attributes on heading elements.
- **`web/src/components/MemoContent/index.tsx`** (157 lines) — ReactMarkdown renderer. Maps h1h6 to the `Heading` component (lines 72101). No rehype-slug or equivalent plugin is installed.
- **`web/src/utils/markdown-manipulation.ts`** (143 lines) — Contains `fromMarkdown()` MDAST parsing with GFM extensions. Used for task extraction; no heading extraction exists.
## Non-Goals
- Redesigning the overall MemoDetail layout or sidebar structure beyond adding the outline section.
- Supporting outline for the mobile drawer — the outline section should appear in the desktop sidebar only for now.
- Adding active heading highlighting on scroll (scroll-spy behavior).
- Supporting outline in the memo list/timeline view (compact mode).
- Changing the existing heading visual styles in the content area.
## Open Questions
- Which heading levels to include in the outline? (default: h1h4, matching user request)
- How to generate slug IDs — install `rehype-slug` or custom implementation? (default: lightweight custom slug generation in the Heading component to avoid a new dependency)
- Should the outline section be hidden when no headings exist? (default: yes, hide entirely like Properties/Tags sections)
- Should duplicate heading text produce unique slugs (e.g. `my-heading`, `my-heading-1`)? (default: yes, append numeric suffix for duplicates)
## Scope
**M** — Touches 34 files (Heading component, MemoDetailSidebar, possibly a new outline component, and a slug utility). Existing patterns (SidebarSection, hash scrolling) apply directly. No new subsystem or novel approach required.

View File

@ -0,0 +1,47 @@
## Task List
T1: Add heading extraction utility [S] — T2: Add slug IDs to Heading component [S] — T3: Create MemoOutline sidebar component [M] — T4: Integrate outline into MemoDetailSidebar [S]
### T1: Add heading extraction utility [S]
**Objective**: Provide a function to extract h1h4 headings from markdown content with slugified IDs, reusing the existing MDAST parsing pattern from `markdown-manipulation.ts`.
**Files**: `web/src/utils/markdown-manipulation.ts`
**Implementation**: Add `HeadingItem` interface (text, level, slug) and `extractHeadings(markdown: string): HeadingItem[]` function. Use existing `fromMarkdown()` + `visit()` pattern. Visit `"heading"` nodes with depth 14, extract text from children, generate slug via `slugify()` helper (lowercase, replace non-alphanumeric with hyphens, deduplicate). Export both.
**Validation**: `cd web && pnpm lint` — no new errors
### T2: Add slug IDs to Heading component [S]
**Objective**: Generate deterministic `id` attributes on h1h6 elements so outline links can scroll to them via `#hash`.
**Files**: `web/src/components/MemoContent/markdown/Heading.tsx`
**Implementation**: In `Heading` (~line 13), extract text from `children` using a `getTextContent(children)` helper that recursively extracts string content from React children. Generate slug with the same `slugify` logic. Apply `id={slug}` to the rendered `<Component>`.
**Validation**: `cd web && pnpm lint` — no new errors
### T3: Create MemoOutline sidebar component [M]
**Objective**: Create a modern, Claude/Linear-style outline component that renders h1h4 headings as anchor links with indentation by level.
**Size**: M (new component file, modern styling)
**Files**:
- Create: `web/src/components/MemoDetailSidebar/MemoOutline.tsx`
**Implementation**:
1. Props: `{ headings: HeadingItem[] }` from `markdown-manipulation.ts`
2. Render a `<nav>` with vertical list of `<a href="#slug">` links
3. Styling per level: h1 no indent, h2 `pl-3`, h3 `pl-6`, h4 `pl-9`. Text size: h1 `text-[13px] font-medium`, h2h4 `text-[13px] font-normal`. Color: `text-muted-foreground` with `hover:text-foreground` transition. Left border accent line (2px) along the nav. Smooth scroll on click via `scrollIntoView`.
4. Each link: `block py-1 truncate transition-colors` with level-based indentation
**Boundaries**: No scroll-spy / active state tracking. No mobile drawer integration.
**Dependencies**: T1
**Expected Outcome**: Component renders a clean, modern outline navigation.
**Validation**: `cd web && pnpm lint` — no new errors
### T4: Integrate outline into MemoDetailSidebar [S]
**Objective**: Add the outline section as the first section in `MemoDetailSidebar`, shown only when headings exist.
**Files**: `web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx`
**Implementation**: Import `extractHeadings` and `MemoOutline`. In `MemoDetailSidebar` (~line 48), compute `headings = useMemo(() => extractHeadings(memo.content), [memo.content])`. Before the Share section (~line 58), add conditional: `{headings.length > 0 && <SidebarSection label="Outline"><MemoOutline headings={headings} /></SidebarSection>}`.
**Validation**: `cd web && pnpm lint && pnpm build` — no errors
## Out-of-Scope Tasks
- Scroll-spy / active heading highlighting in the outline
- Mobile drawer outline support
- Outline in memo list view (compact mode)
- Changing existing heading visual styles in content area

View File

@ -10,6 +10,7 @@ import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
@ -51,7 +52,12 @@ const MemoContent = (props: MemoContentProps) => {
>
<ReactMarkdown
remarkPlugins={[remarkDisableSetext, remarkMath, remarkGfm, remarkBreaks, remarkTag, remarkPreserveType]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], [rehypeKatex, { throwOnError: false, strict: false }]]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, SANITIZE_SCHEMA],
rehypeHeadingId,
[rehypeKatex, { throwOnError: false, strict: false }],
]}
components={{
// Child components consume from MemoViewContext directly
input: ((inputProps: React.ComponentProps<"input"> & { node?: Element }) => {

View File

@ -7,8 +7,9 @@ interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement>, ReactMa
}
/**
* Heading component for h1-h6 elements
* Renders semantic heading levels with consistent styling
* Heading component for h1-h6 elements.
* Renders semantic heading levels with consistent styling.
* Anchor IDs are assigned by the rehypeHeadingId plugin.
*/
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
const Component = `h${level}` as const;

View File

@ -8,7 +8,9 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { Memo, Memo_PropertySchema } from "@/types/proto/api/v1/memo_service_pb";
import { type Translations, useTranslate } from "@/utils/i18n";
import { extractHeadings } from "@/utils/markdown-manipulation";
import { isSuperUser } from "@/utils/user";
import MemoOutline from "./MemoOutline";
import MemoSharePanel from "./MemoSharePanel";
interface Props {
@ -44,6 +46,7 @@ const MemoDetailSidebar = ({ memo, className }: Props) => {
const property = create(Memo_PropertySchema, memo.property || {});
const canManageShares = !memo.parent && (memo.creator === currentUser?.name || isSuperUser(currentUser));
const hasUpdated = !isEqual(memo.createTime, memo.updateTime);
const headings = useMemo(() => extractHeadings(memo.content), [memo.content]);
const propertyBadges = useMemo(() => {
const badges: PropertyBadge[] = [];
@ -55,6 +58,12 @@ const MemoDetailSidebar = ({ memo, className }: Props) => {
return (
<aside className={cn("relative w-full h-auto max-h-screen overflow-auto flex flex-col gap-5", className)}>
{headings.length > 0 && (
<SidebarSection label={t("memo.outline")}>
<MemoOutline headings={headings} />
</SidebarSection>
)}
{canManageShares && (
<SidebarSection label={t("memo.share.section-label")}>
<Button variant="outline" className="w-full justify-start gap-2" onClick={() => setSharePanelOpen(true)}>

View File

@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import type { HeadingItem } from "@/utils/markdown-manipulation";
interface MemoOutlineProps {
headings: HeadingItem[];
}
const levelIndent: Record<number, string> = {
1: "ml-0",
2: "ml-3",
3: "ml-6",
4: "ml-8",
};
/** Outline navigation for memo headings (h1h4). */
const MemoOutline = ({ headings }: MemoOutlineProps) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
e.preventDefault();
const el = document.getElementById(slug);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", `#${slug}`);
}
};
return (
<nav className="relative flex flex-col">
{headings.map((heading, index) => (
<a
key={`${heading.slug}-${index}`}
href={`#${heading.slug}`}
onClick={(e) => handleClick(e, heading.slug)}
className={cn(
"group relative block py-[5px] pr-1 text-[13px] leading-snug truncate",
"text-muted-foreground/60 hover:text-foreground/90",
"transition-colors duration-200 ease-out",
levelIndent[heading.level],
heading.level === 1 && "font-medium text-muted-foreground/80",
)}
title={heading.text}
>
<span className="relative">
{heading.text}
<span className="absolute -bottom-px left-0 h-px w-0 bg-foreground/30 transition-all duration-200 group-hover:w-full" />
</span>
</a>
))}
</nav>
);
};
export default MemoOutline;

View File

@ -172,6 +172,7 @@
"no-archived-memos": "No archived memos.",
"no-memos": "No memos.",
"order-by": "Order By",
"outline": "Outline",
"search-placeholder": "Search memos...",
"share": {
"active-links": "Active share links",

View File

@ -1,7 +1,7 @@
// Utilities for manipulating markdown strings using AST parsing
// Uses mdast for accurate task detection that properly handles code blocks
import type { ListItem } from "mdast";
import type { Heading, ListItem } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmFromMarkdown } from "mdast-util-gfm";
import { gfm } from "micromark-extension-gfm";
@ -105,6 +105,65 @@ export interface TaskItem {
indentation: number;
}
export interface HeadingItem {
text: string;
level: 1 | 2 | 3 | 4;
slug: string;
}
/**
* Slugify a string into a URL-friendly anchor ID.
*/
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Extract h1h4 headings from markdown content for outline navigation.
*/
export function extractHeadings(markdown: string): HeadingItem[] {
const tree = fromMarkdown(markdown, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
});
const headings: HeadingItem[] = [];
const slugCounts = new Map<string, number>();
visit(tree, "heading", (node: Heading) => {
if (node.depth < 1 || node.depth > 4) return;
const text = getNodeText(node as unknown as MdastNode);
if (!text) return;
let slug = slugify(text);
const count = slugCounts.get(slug) || 0;
slugCounts.set(slug, count + 1);
if (count > 0) slug = `${slug}-${count}`;
headings.push({ text, level: node.depth as 1 | 2 | 3 | 4, slug });
});
return headings;
}
interface MdastNode {
value?: string;
children?: MdastNode[];
}
function getNodeText(node: MdastNode): string {
if (node.value) return node.value;
if (node.children) return node.children.map(getNodeText).join("");
return "";
}
export function extractTasks(markdown: string): TaskItem[] {
const tree = fromMarkdown(markdown, {
extensions: [gfm()],

View File

@ -0,0 +1,37 @@
import type { Element, Root } from "hast";
import { visit } from "unist-util-visit";
import { slugify } from "@/utils/markdown-manipulation";
function getTextContent(node: Element): string {
let text = "";
for (const child of node.children) {
if (child.type === "text") {
text += child.value;
} else if (child.type === "element") {
text += getTextContent(child);
}
}
return text;
}
/** Rehype plugin that adds unique slugified `id` attributes to heading elements. */
export const rehypeHeadingId = () => {
return (tree: Root) => {
const slugCounts = new Map<string, number>();
visit(tree, "element", (node: Element) => {
if (!/^h[1-6]$/.test(node.tagName)) return;
const text = getTextContent(node);
let slug = slugify(text);
if (!slug) return;
const count = slugCounts.get(slug) || 0;
slugCounts.set(slug, count + 1);
if (count > 0) slug = `${slug}-${count}`;
node.properties = node.properties || {};
node.properties.id = slug;
});
};
};