mirror of https://github.com/usememos/memos
feat: implement markdown components for enhanced rendering
parent
c0d6224155
commit
7154ce0228
@ -0,0 +1,83 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
|
||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-border my-4">
|
||||
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableHead = ({ children, className, node: _node, ...props }: TableHeadProps) => {
|
||||
return (
|
||||
<thead className={cn("bg-accent", className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableBody = ({ children, className, node: _node, ...props }: TableBodyProps) => {
|
||||
return (
|
||||
<tbody className={cn("divide-y divide-border", className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableRow = ({ children, className, node: _node, ...props }: TableRowProps) => {
|
||||
return (
|
||||
<tr className={cn("transition-colors hover:bg-muted/50", "even:bg-accent/50", className)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground",
|
||||
"border-b-2 border-border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
|
||||
return (
|
||||
<td className={cn("px-4 py-3 text-left", className)} {...props}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockquote component with left border accent
|
||||
*/
|
||||
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
|
||||
return (
|
||||
<blockquote className={cn("my-0 mb-2 border-l-4 border-border pl-3 text-muted-foreground", className)} {...props}>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement>, ReactMarkdownProps {
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading component for h1-h6 elements
|
||||
* Renders semantic heading levels with consistent styling
|
||||
*/
|
||||
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
|
||||
const Component = `h${level}` as const;
|
||||
|
||||
const levelClasses = {
|
||||
1: "text-3xl font-bold border-b border-border pb-1",
|
||||
2: "text-2xl border-b border-border pb-1",
|
||||
3: "text-xl",
|
||||
4: "text-base",
|
||||
5: "text-sm",
|
||||
6: "text-sm text-muted-foreground",
|
||||
};
|
||||
|
||||
return (
|
||||
<Component className={cn("mt-3 mb-2 font-semibold leading-tight", levelClasses[level], className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface HorizontalRuleProps extends React.HTMLAttributes<HTMLHRElement>, ReactMarkdownProps {}
|
||||
|
||||
/**
|
||||
* Horizontal rule separator
|
||||
*/
|
||||
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
|
||||
return <hr className={cn("my-2 h-0 border-0 border-b border-border", className)} {...props} />;
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement>, ReactMarkdownProps {}
|
||||
|
||||
/**
|
||||
* Image component for markdown images
|
||||
* Responsive with rounded corners
|
||||
*/
|
||||
export const Image = ({ className, alt, node: _node, ...props }: ImageProps) => {
|
||||
return <img className={cn("max-w-full h-auto rounded-lg my-2", className)} alt={alt} {...props} />;
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface InlineCodeProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline code component with background and monospace font
|
||||
*/
|
||||
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
|
||||
return (
|
||||
<code className={cn("font-mono text-sm bg-muted px-1 py-0.5 rounded", className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link component for external links
|
||||
* Opens in new tab with security attributes
|
||||
*/
|
||||
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn("text-primary underline transition-opacity hover:opacity-80", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ListProps extends React.HTMLAttributes<HTMLUListElement | HTMLOListElement>, ReactMarkdownProps {
|
||||
ordered?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* List component for both regular and task lists (GFM)
|
||||
* Detects task lists via the "contains-task-list" class added by remark-gfm
|
||||
*/
|
||||
export const List = ({ ordered, children, className, node: _node, ...domProps }: ListProps) => {
|
||||
const Component = ordered ? "ol" : "ul";
|
||||
const isTaskList = className?.includes("contains-task-list");
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"my-0 mb-2 list-outside",
|
||||
isTaskList ? "pl-0 list-none" : cn("pl-6", ordered ? "list-decimal" : "list-disc"),
|
||||
className,
|
||||
)}
|
||||
{...domProps}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* List item component for both regular and task list items
|
||||
* Detects task items via the "task-list-item" class added by remark-gfm
|
||||
* Applies specialized styling for task checkboxes
|
||||
*/
|
||||
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
|
||||
const isTaskListItem = className?.includes("task-list-item");
|
||||
|
||||
if (isTaskListItem) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"mt-0.5 leading-6 list-none",
|
||||
// Task item styles: checkbox margins, inline paragraph, nested list indent
|
||||
"[&>button]:mr-2 [&>button]:align-middle",
|
||||
"[&>p]:inline [&>p]:m-0",
|
||||
"[&>.contains-task-list]:pl-6",
|
||||
className,
|
||||
)}
|
||||
{...domProps}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={cn("mt-0.5 leading-6", className)} {...domProps}>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paragraph component with compact spacing
|
||||
*/
|
||||
export const Paragraph = ({ children, className, node: _node, ...props }: ParagraphProps) => {
|
||||
return (
|
||||
<p className={cn("my-0 mb-2 leading-6", className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
# Markdown Components
|
||||
|
||||
Modern, type-safe React components for rendering markdown content via react-markdown.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component-Based Rendering
|
||||
Following patterns from popular AI chat apps (ChatGPT, Claude, Perplexity), we use React components instead of CSS selectors for markdown rendering. This provides:
|
||||
|
||||
- **Type Safety**: Full TypeScript support with proper prop types
|
||||
- **Maintainability**: Components are easier to test, modify, and understand
|
||||
- **Performance**: No CSS specificity conflicts, cleaner DOM
|
||||
- **Modularity**: Each element is independently styled and documented
|
||||
|
||||
### Type System
|
||||
|
||||
All components extend `ReactMarkdownProps` which includes the AST `node` prop passed by react-markdown. This is explicitly destructured as `node: _node` to:
|
||||
1. Filter it from DOM props (avoids `node="[object Object]"` in HTML)
|
||||
2. Keep it available for advanced use cases (e.g., detecting task lists)
|
||||
3. Maintain type safety without `as any` casts
|
||||
|
||||
### GFM Task Lists
|
||||
|
||||
Task lists (from remark-gfm) are handled by:
|
||||
- **Detection**: `contains-task-list` and `task-list-item` classes from remark-gfm
|
||||
- **Styling**: Tailwind utilities with arbitrary variants for nested elements
|
||||
- **Checkboxes**: Custom `TaskListItem` component with Radix UI checkbox
|
||||
- **Interactivity**: Updates memo content via `toggleTaskAtIndex` utility
|
||||
|
||||
### Component Patterns
|
||||
|
||||
Each component follows this structure:
|
||||
```tsx
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ComponentProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
|
||||
children?: React.ReactNode;
|
||||
// component-specific props
|
||||
}
|
||||
|
||||
/**
|
||||
* JSDoc description
|
||||
*/
|
||||
export const Component = ({ children, className, node: _node, ...props }: ComponentProps) => {
|
||||
return (
|
||||
<element className={cn("base-classes", className)} {...props}>
|
||||
{children}
|
||||
</element>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Element | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| `Heading` | h1-h6 | Semantic headings with level-based styling |
|
||||
| `Paragraph` | p | Compact paragraphs with consistent spacing |
|
||||
| `Link` | a | External links with security attributes |
|
||||
| `List` | ul/ol | Regular and GFM task lists |
|
||||
| `ListItem` | li | List items with task checkbox support |
|
||||
| `Blockquote` | blockquote | Quotes with left border accent |
|
||||
| `InlineCode` | code | Inline code with background |
|
||||
| `Image` | img | Responsive images with rounded corners |
|
||||
| `HorizontalRule` | hr | Section separators |
|
||||
|
||||
## Styling Approach
|
||||
|
||||
- **Tailwind CSS**: All styling uses Tailwind utilities
|
||||
- **Design Tokens**: Colors use CSS variables (e.g., `--primary`, `--muted-foreground`)
|
||||
- **Responsive**: Max-width constraints, responsive images
|
||||
- **Accessibility**: Semantic HTML, proper ARIA attributes via Radix UI
|
||||
|
||||
## Integration
|
||||
|
||||
Components are mapped to HTML elements in `MemoContent/index.tsx`:
|
||||
|
||||
```tsx
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => <Heading level={1}>{children}</Heading>,
|
||||
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
|
||||
// ... more mappings
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Syntax highlighting themes for code blocks
|
||||
- [ ] Table sorting/filtering interactions
|
||||
- [ ] Image lightbox/zoom functionality
|
||||
- [ ] Collapsible sections for long content
|
||||
- [ ] Copy button for code blocks
|
||||
@ -0,0 +1,8 @@
|
||||
export { Blockquote } from "./Blockquote";
|
||||
export { Heading } from "./Heading";
|
||||
export { HorizontalRule } from "./HorizontalRule";
|
||||
export { Image } from "./Image";
|
||||
export { InlineCode } from "./InlineCode";
|
||||
export { Link } from "./Link";
|
||||
export { List, ListItem } from "./List";
|
||||
export { Paragraph } from "./Paragraph";
|
||||
@ -0,0 +1,9 @@
|
||||
import type { Element } from "hast";
|
||||
|
||||
/**
|
||||
* Props passed by react-markdown to custom components
|
||||
* Includes the AST node for advanced use cases
|
||||
*/
|
||||
export interface ReactMarkdownProps {
|
||||
node?: Element;
|
||||
}
|
||||
Loading…
Reference in New Issue