Commit 18e729fe authored by boojack's avatar boojack

chore(memo): simplify markdown task list rendering

parent 9e310bf9
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 };
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>
);
};
import type { Element } from "hast";
import { ChevronDown, ChevronUp } from "lucide-react";
import { memo, useMemo } from "react";
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 { 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 { extractMentionUsernames, 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 { isMentionNode, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
import { COMPACT_MODE_CONFIG } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks";
import { Mention } from "./Mention";
import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
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";
import type { MemoContentProps } from "./types";
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 "";
}
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate();
......@@ -79,104 +40,7 @@ const MemoContent = (props: MemoContentProps) => {
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
<ReactMarkdown
remarkPlugins={[
remarkDisableSetext,
remarkMath,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
remarkMention,
remarkTag,
remarkPreserveType,
]}
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 }) => {
const { node, ...rest } = inputProps;
if (node && isTaskListItemNode(node)) {
return <TaskListItem {...inputProps} />;
}
return <input {...rest} />;
}) as React.ComponentType<React.ComponentProps<"input">>,
span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => {
const { node, ...rest } = spanProps;
if (node && isMentionNode(node)) {
const username = getMentionUsername(node, spanProps.children);
return <Mention {...spanProps} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
}
if (node && isTagNode(node)) {
return <Tag {...spanProps} />;
}
return <span {...rest} />;
}) as React.ComponentType<React.ComponentProps<"span">>,
// Headings
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>
),
// Block elements
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
blockquote: ({ children, ...props }) => <Blockquote {...props}>{children}</Blockquote>,
hr: (props) => <HorizontalRule {...props} />,
// Lists
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
ol: ({ children, ...props }) => (
<List ordered {...props}>
{children}
</List>
),
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
// Inline elements
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
iframe: TrustedIframe as React.ComponentType<React.ComponentProps<"iframe">>,
img: ({ ...props }) => <Image {...props} />,
// Code blocks
pre: CodeBlock,
// Tables
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>,
}}
>
{content}
</ReactMarkdown>
<MemoMarkdownRenderer content={content} resolvedMentionUsernames={resolvedMentionUsernames} />
{showCompactMode === "ALL" && (
<div
className={cn(
......
......@@ -20,8 +20,8 @@ export const List = ({ ordered, children, className, node: _node, ...domProps }:
className={cn(
"my-0 mb-2 list-outside",
isTaskList
? // Task list: no bullets, nested lists get left margin for indentation
"list-none [&_ul.contains-task-list]:ml-6"
? // Task list indentation is handled by task item grid columns.
"list-none"
: // Regular list: standard padding and list style
cn("pl-6", ordered ? "list-decimal" : "list-disc"),
className,
......@@ -40,7 +40,6 @@ interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMark
/**
* 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_CLASS);
......@@ -49,11 +48,9 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List
return (
<li
className={cn(
"mt-0.5 leading-6 list-none",
// Checkbox styling: margin and alignment
"[&>button]:mr-2 [&>button]:align-middle",
// Inline paragraph for task text
"[&>p]:inline [&>p]:m-0",
"mt-0.5 leading-6 list-none grid grid-cols-[auto_1fr] items-center gap-x-2",
"[&>ul]:col-start-2 [&>ul]:col-span-1 [&>ol]:col-start-2 [&>ol]:col-span-1",
"[&>p:first-child]:contents [&>p:not(:first-child)]:col-start-2 [&>p:not(:first-child)]:col-span-1",
className,
)}
{...domProps}
......
# 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.
import type { List, ListItem, Root } from "mdast";
import type { Parent } from "unist";
const isTaskListItem = (item: ListItem): boolean => typeof item.checked === "boolean";
const isTaskItem = (item: ListItem): boolean => typeof item.checked === "boolean";
const isSingleBlockItem = (item: ListItem): boolean => item.children.length <= 1;
const hasLooseBlockItem = (item: ListItem): boolean => !isSingleBlockItem(item) && Boolean(item.spread);
const normalizeSplitListItems = (items: ListItem[]): ListItem[] =>
items.map((item) => (isSingleBlockItem(item) ? { ...item, spread: false } : item));
const splitMixedList = (list: List): List[] => {
const hasTaskItem = list.children.some(isTaskListItem);
const hasRegularItem = list.children.some((item) => !isTaskListItem(item));
const hasTaskItem = list.children.some(isTaskItem);
const hasRegularItem = list.children.some((item) => !isTaskItem(item));
if (!hasTaskItem || !hasRegularItem) {
return [list];
......@@ -13,7 +20,7 @@ const splitMixedList = (list: List): List[] => {
const groups: Array<{ isTaskGroup: boolean; items: ListItem[] }> = [];
for (const item of list.children) {
const isTaskGroup = isTaskListItem(item);
const isTaskGroup = isTaskItem(item);
const previousGroup = groups.at(-1);
if (previousGroup && previousGroup.isTaskGroup === isTaskGroup) {
......@@ -23,11 +30,14 @@ const splitMixedList = (list: List): List[] => {
}
}
return groups.map(({ isTaskGroup, items }) => ({
...list,
children: isTaskGroup ? items : items.map((item) => ({ ...item, spread: false })),
spread: isTaskGroup ? list.spread : false,
}));
return groups.map(({ items }) => {
const children = normalizeSplitListItems(items);
return {
...list,
children,
spread: children.some(hasLooseBlockItem),
};
});
};
const splitMixedTaskListsInParent = (parent: Parent): void => {
......
......@@ -34,6 +34,8 @@ describe("memo content lists", () => {
expect(html).toContain('<li class="mt-0.5 leading-6">milk</li>');
expect(html).not.toContain('<li class="mt-0.5 leading-6">\n<p>milk</p>');
expect(html).toContain(TASK_LIST_ITEM_CLASS);
expect(html).toContain("grid grid-cols-[auto_1fr] items-center gap-x-2");
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
});
it("keeps compact styling for pure task lists", () => {
......@@ -41,5 +43,25 @@ describe("memo content lists", () => {
expect(html).toMatch(/<ul class="[^"]*\blist-none\b[^"]*"/);
expect(html).not.toMatch(/<ul class="[^"]*\blist-disc\b[^"]*"/);
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
});
it("keeps nested task lists on their own row", () => {
const html = renderListContent("- [ ] asdas\n - [ ] zzzz");
expect(html).toContain("grid grid-cols-[auto_1fr] items-center gap-x-2");
expect(html).toContain("[&amp;&gt;ul]:col-start-2");
expect(html).not.toContain("[&amp;_ul.contains-task-list]:ml-6");
expect(html).toContain("zzzz");
});
it("keeps loose task list paragraphs while aligning the first line", () => {
const html = renderListContent("- [ ] plan\n\n keep details\n\n- [ ] zzzz");
expect(html).toMatch(/<li class="[^"]*task-list-item[^"]*">\s*<p>/);
expect(html).toContain("[&amp;&gt;p:first-child]:contents");
expect(html).toContain("[&amp;&gt;p:not(:first-child)]:col-start-2");
expect(html).toContain("<p>keep details</p>");
expect(html).toContain("zzzz");
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment