mirror of https://github.com/usememos/memos
chore(memo): simplify markdown task list rendering
parent
9e310bf93c
commit
18e729fe00
@ -1,36 +0,0 @@
|
||||
import type { Element } from "hast";
|
||||
import React from "react";
|
||||
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
|
||||
|
||||
/**
|
||||
* Creates a conditional component that renders different components
|
||||
* based on AST node type detection
|
||||
*
|
||||
* @param CustomComponent - Custom component to render when condition matches
|
||||
* @param DefaultComponent - Default component/element to render otherwise
|
||||
* @param condition - Function to test AST node
|
||||
* @returns Conditional wrapper component
|
||||
*/
|
||||
export const createConditionalComponent = <P extends Record<string, unknown>>(
|
||||
CustomComponent: React.ComponentType<P>,
|
||||
DefaultComponent: React.ComponentType<P> | keyof JSX.IntrinsicElements,
|
||||
condition: (node: Element) => boolean,
|
||||
) => {
|
||||
return (props: P & { node?: Element }) => {
|
||||
const { node, ...restProps } = props;
|
||||
|
||||
// Check AST node to determine which component to use
|
||||
if (node && condition(node)) {
|
||||
return <CustomComponent {...(restProps as P)} node={node} />;
|
||||
}
|
||||
|
||||
// Render default component/element
|
||||
if (typeof DefaultComponent === "string") {
|
||||
return React.createElement(DefaultComponent, restProps);
|
||||
}
|
||||
return <DefaultComponent {...(restProps as P)} />;
|
||||
};
|
||||
};
|
||||
|
||||
// Re-export type guards for convenience
|
||||
export { isMentionElement as isMentionNode, isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };
|
||||
@ -0,0 +1,139 @@
|
||||
import type { Element } from "hast";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
|
||||
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
|
||||
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
|
||||
import { remarkMention } from "@/utils/remark-plugins/remark-mention";
|
||||
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
|
||||
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
|
||||
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { SANITIZE_SCHEMA } from "./constants";
|
||||
import { Mention } from "./Mention";
|
||||
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
|
||||
import { Tag } from "./Tag";
|
||||
import { TaskListItem } from "./TaskListItem";
|
||||
import { TrustedIframe } from "./TrustedIframe";
|
||||
|
||||
interface MemoMarkdownRendererProps {
|
||||
content: string;
|
||||
resolvedMentionUsernames: Set<string>;
|
||||
}
|
||||
|
||||
function getMentionUsername(node: Element, children?: React.ReactNode): string {
|
||||
const dataMention = node.properties?.["data-mention"];
|
||||
if (typeof dataMention === "string" && dataMention !== "") {
|
||||
return dataMention;
|
||||
}
|
||||
|
||||
const camelDataMention = (node.properties as Record<string, unknown> | undefined)?.dataMention;
|
||||
if (typeof camelDataMention === "string" && camelDataMention !== "") {
|
||||
return camelDataMention;
|
||||
}
|
||||
|
||||
const text = Array.isArray(children) ? children.join("") : children;
|
||||
if (typeof text === "string" && text.startsWith("@")) {
|
||||
return text.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: MemoMarkdownRendererProps) => {
|
||||
const markdownComponents: Components = {
|
||||
input: ({ node, ...inputProps }) => {
|
||||
if (node && isTaskListItemElement(node)) {
|
||||
return <TaskListItem {...inputProps} node={node} />;
|
||||
}
|
||||
return <input {...inputProps} />;
|
||||
},
|
||||
span: ({ node, ...spanProps }) => {
|
||||
if (node && isMentionElement(node)) {
|
||||
const username = getMentionUsername(node, spanProps.children);
|
||||
return <Mention {...spanProps} node={node} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
|
||||
}
|
||||
if (node && isTagElement(node)) {
|
||||
return <Tag {...spanProps} node={node} />;
|
||||
}
|
||||
return <span {...spanProps} />;
|
||||
},
|
||||
h1: ({ children, ...props }) => (
|
||||
<Heading level={1} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<Heading level={2} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<Heading level={3} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<Heading level={4} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h5: ({ children, ...props }) => (
|
||||
<Heading level={5} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h6: ({ children, ...props }) => (
|
||||
<Heading level={6} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
|
||||
blockquote: ({ children, ...props }) => <Blockquote {...props}>{children}</Blockquote>,
|
||||
hr: (props) => <HorizontalRule {...props} />,
|
||||
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
|
||||
ol: ({ children, ...props }) => (
|
||||
<List ordered {...props}>
|
||||
{children}
|
||||
</List>
|
||||
),
|
||||
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
|
||||
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
|
||||
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
|
||||
iframe: TrustedIframe,
|
||||
img: (props) => <Image {...props} />,
|
||||
pre: CodeBlock,
|
||||
table: ({ children, ...props }) => <Table {...props}>{children}</Table>,
|
||||
thead: ({ children, ...props }) => <TableHead {...props}>{children}</TableHead>,
|
||||
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
|
||||
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
|
||||
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
|
||||
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
remarkDisableSetext,
|
||||
remarkMath,
|
||||
remarkGfm,
|
||||
remarkSplitMixedTaskLists,
|
||||
remarkBreaks,
|
||||
remarkMention,
|
||||
remarkTag,
|
||||
remarkPreserveType,
|
||||
]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], rehypeHeadingId, [rehypeKatex, { throwOnError: false, strict: false }]]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
@ -1,97 +1,18 @@
|
||||
# Markdown Components
|
||||
|
||||
Modern, type-safe React components for rendering markdown content via react-markdown.
|
||||
Small React components used by `MemoMarkdownRenderer` to style HTML emitted by `react-markdown`.
|
||||
|
||||
## Architecture
|
||||
## Responsibilities
|
||||
|
||||
### 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:
|
||||
- Keep element styling local to each semantic HTML element.
|
||||
- Strip the `node` prop from DOM output through `ReactMarkdownProps`.
|
||||
- Preserve existing markdown behavior while avoiding structural fixes in CSS.
|
||||
|
||||
- **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
|
||||
## Task Lists
|
||||
|
||||
### Type System
|
||||
GFM task lists are normalized before rendering by `remarkSplitMixedTaskLists`.
|
||||
|
||||
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
|
||||
- Mixed task/bullet lists are split into separate lists so regular bullets keep bullets.
|
||||
- Single-block split items are rendered as tight list items, preventing accidental `<p>` wrappers.
|
||||
- `ListItem` uses a two-column grid: checkbox/control in the first column, task text and nested content in the second.
|
||||
- Loose task items keep paragraph structure; the first paragraph contributes its checkbox/text to the grid, while later paragraphs align with the text column.
|
||||
|
||||
Loading…
Reference in New Issue