mirror of https://github.com/usememos/memos.git
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:
parent
2aaca27bd5
commit
26d449d7a5
|
|
@ -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 (h1–h6) in `MemoContent/markdown/Heading.tsx`. The page already has hash-based scroll-to-element infrastructure for comments (lines 39–43 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 39–43 but only targets comment elements. The sidebar is rendered at lines 82–86 as `<MemoDetailSidebar>`.
|
||||
- **`web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx`** (109 lines) — Desktop sidebar. Contains sections: Share (lines 58–65), Created-at (lines 67–76), Properties (lines 78–89), Tags (lines 91–102). Uses a reusable `SidebarSection` helper (lines 24–32). 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 h1–h6 with Tailwind classes. Does not generate `id` attributes on heading elements.
|
||||
- **`web/src/components/MemoContent/index.tsx`** (157 lines) — ReactMarkdown renderer. Maps h1–h6 to the `Heading` component (lines 72–101). 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: h1–h4, 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 3–4 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.
|
||||
|
|
@ -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 h1–h4 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 1–4, 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 h1–h6 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 h1–h4 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`, h2–h4 `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
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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 (h1–h4). */
|
||||
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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 h1–h4 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()],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue