mirror of https://github.com/usememos/memos.git
feat: add outline navigation to memo detail sidebar (#5771)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
2aaca27bd5
commit
6b30579903
|
|
@ -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