From 18e729fe0037e740649f87feaa53c1dadf7cc8e9 Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 27 Apr 2026 23:22:29 +0800 Subject: [PATCH] chore(memo): simplify markdown task list rendering --- .../MemoContent/ConditionalComponent.tsx | 36 ----- .../MemoContent/MemoMarkdownRenderer.tsx | 139 +++++++++++++++++ web/src/components/MemoContent/index.tsx | 144 +----------------- .../components/MemoContent/markdown/List.tsx | 13 +- .../components/MemoContent/markdown/README.md | 101 ++---------- .../remark-split-mixed-task-lists.ts | 28 ++-- web/tests/memo-content-list.test.tsx | 22 +++ 7 files changed, 200 insertions(+), 283 deletions(-) delete mode 100644 web/src/components/MemoContent/ConditionalComponent.tsx create mode 100644 web/src/components/MemoContent/MemoMarkdownRenderer.tsx diff --git a/web/src/components/MemoContent/ConditionalComponent.tsx b/web/src/components/MemoContent/ConditionalComponent.tsx deleted file mode 100644 index 6bb1142e6..000000000 --- a/web/src/components/MemoContent/ConditionalComponent.tsx +++ /dev/null @@ -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 =

>( - CustomComponent: React.ComponentType

, - DefaultComponent: React.ComponentType

| 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 ; - } - - // Render default component/element - if (typeof DefaultComponent === "string") { - return React.createElement(DefaultComponent, restProps); - } - return ; - }; -}; - -// Re-export type guards for convenience -export { isMentionElement as isMentionNode, isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode }; diff --git a/web/src/components/MemoContent/MemoMarkdownRenderer.tsx b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx new file mode 100644 index 000000000..159c6f4dd --- /dev/null +++ b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx @@ -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; +} + +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 | 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 ; + } + return ; + }, + span: ({ node, ...spanProps }) => { + if (node && isMentionElement(node)) { + const username = getMentionUsername(node, spanProps.children); + return ; + } + if (node && isTagElement(node)) { + return ; + } + return ; + }, + h1: ({ children, ...props }) => ( + + {children} + + ), + h2: ({ children, ...props }) => ( + + {children} + + ), + h3: ({ children, ...props }) => ( + + {children} + + ), + h4: ({ children, ...props }) => ( + + {children} + + ), + h5: ({ children, ...props }) => ( + + {children} + + ), + h6: ({ children, ...props }) => ( + + {children} + + ), + p: ({ children, ...props }) => {children}, + blockquote: ({ children, ...props }) =>

{children}
, + hr: (props) => , + ul: ({ children, ...props }) => {children}, + ol: ({ children, ...props }) => ( + + {children} + + ), + li: ({ children, ...props }) => {children}, + a: ({ children, ...props }) => {children}, + code: ({ children, ...props }) => {children}, + iframe: TrustedIframe, + img: (props) => , + pre: CodeBlock, + table: ({ children, ...props }) => {children}
, + thead: ({ children, ...props }) => {children}, + tbody: ({ children, ...props }) => {children}, + tr: ({ children, ...props }) => {children}, + th: ({ children, ...props }) => {children}, + td: ({ children, ...props }) => {children}, + }; + + return ( + + {content} + + ); +}; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 232d434bd..67fa19953 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -1,53 +1,14 @@ -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 | 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} > - & { node?: Element }) => { - const { node, ...rest } = inputProps; - if (node && isTaskListItemNode(node)) { - return ; - } - return ; - }) as React.ComponentType>, - span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => { - const { node, ...rest } = spanProps; - if (node && isMentionNode(node)) { - const username = getMentionUsername(node, spanProps.children); - return ; - } - if (node && isTagNode(node)) { - return ; - } - return ; - }) as React.ComponentType>, - // Headings - h1: ({ children, ...props }) => ( - - {children} - - ), - h2: ({ children, ...props }) => ( - - {children} - - ), - h3: ({ children, ...props }) => ( - - {children} - - ), - h4: ({ children, ...props }) => ( - - {children} - - ), - h5: ({ children, ...props }) => ( - - {children} - - ), - h6: ({ children, ...props }) => ( - - {children} - - ), - // Block elements - p: ({ children, ...props }) => {children}, - blockquote: ({ children, ...props }) =>
{children}
, - hr: (props) => , - // Lists - ul: ({ children, ...props }) => {children}, - ol: ({ children, ...props }) => ( - - {children} - - ), - li: ({ children, ...props }) => {children}, - // Inline elements - a: ({ children, ...props }) => {children}, - code: ({ children, ...props }) => {children}, - iframe: TrustedIframe as React.ComponentType>, - img: ({ ...props }) => , - // Code blocks - pre: CodeBlock, - // Tables - table: ({ children, ...props }) => {children}
, - thead: ({ children, ...props }) => {children}, - tbody: ({ children, ...props }) => {children}, - tr: ({ children, ...props }) => {children}, - th: ({ children, ...props }) => {children}, - td: ({ children, ...props }) => {children}, - }} - > - {content} -
+ {showCompactMode === "ALL" && (
, 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 (
  • 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} diff --git a/web/src/components/MemoContent/markdown/README.md b/web/src/components/MemoContent/markdown/README.md index a6ba08cc7..ece9757af 100644 --- a/web/src/components/MemoContent/markdown/README.md +++ b/web/src/components/MemoContent/markdown/README.md @@ -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, ReactMarkdownProps { - children?: React.ReactNode; - // component-specific props -} - -/** - * JSDoc description - */ -export const Component = ({ children, className, node: _node, ...props }: ComponentProps) => { - return ( - - {children} - - ); -}; -``` - -## 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 - {children}, - p: ({ children, ...props }) => {children}, - // ... more mappings - }} -> - {content} - -``` - -## 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 `

    ` 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. diff --git a/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts b/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts index 5957ebc83..4a1d9a99e 100644 --- a/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts +++ b/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts @@ -1,11 +1,18 @@ 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 => { diff --git a/web/tests/memo-content-list.test.tsx b/web/tests/memo-content-list.test.tsx index cfc6ecb4a..9b5abfd01 100644 --- a/web/tests/memo-content-list.test.tsx +++ b/web/tests/memo-content-list.test.tsx @@ -34,6 +34,8 @@ describe("memo content lists", () => { expect(html).toContain('

  • milk
  • '); expect(html).not.toContain('
  • \n

    milk

    '); expect(html).toContain(TASK_LIST_ITEM_CLASS); + expect(html).toContain("grid grid-cols-[auto_1fr] items-center gap-x-2"); + expect(html).not.toMatch(/
  • { @@ -41,5 +43,25 @@ describe("memo content lists", () => { expect(html).toMatch(/
      { + const html = renderListContent("- [ ] asdas\n - [ ] zzzz"); + + expect(html).toContain("grid grid-cols-[auto_1fr] items-center gap-x-2"); + expect(html).toContain("[&>ul]:col-start-2"); + expect(html).not.toContain("[&_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(/
    • \s*

      /); + expect(html).toContain("[&>p:first-child]:contents"); + expect(html).toContain("[&>p:not(:first-child)]:col-start-2"); + expect(html).toContain("

      keep details

      "); + expect(html).toContain("zzzz"); }); });