From 7c3bff4e98223fb99a5d49f093e3cd57dd140ba4 Mon Sep 17 00:00:00 2001 From: boojack Date: Wed, 27 May 2026 23:57:23 +0800 Subject: [PATCH] fix(markdown): keep task item content in one grid column --- .../components/MemoContent/markdown/List.tsx | 68 +++++++++++++++++-- .../components/MemoContent/markdown/README.md | 5 +- web/tests/memo-content-list.test.tsx | 22 ++++-- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/web/src/components/MemoContent/markdown/List.tsx b/web/src/components/MemoContent/markdown/List.tsx index f16788f6..cae3ea23 100644 --- a/web/src/components/MemoContent/markdown/List.tsx +++ b/web/src/components/MemoContent/markdown/List.tsx @@ -1,8 +1,65 @@ +import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants"; import { NestedMarkdownRenderContext } from "../MarkdownRenderContext"; import type { ReactMarkdownProps } from "./types"; +interface TaskListChildProps { + children?: ReactNode; + node?: { + tagName?: string; + properties?: { + type?: unknown; + }; + }; + type?: string; +} + +const isCheckboxInput = (child: ReactNode): child is ReactElement => { + return ( + isValidElement(child) && (child.props.type === "checkbox" || child.props.node?.properties?.type === "checkbox") + ); +}; + +const isParagraphElement = (child: ReactNode): child is ReactElement => { + return isValidElement(child) && (child.type === "p" || child.props.node?.tagName === "p"); +}; + +const splitTaskListItemChildren = (children: ReactNode) => { + let checkbox: ReactNode; + const content: ReactNode[] = []; + + Children.toArray(children).forEach((child) => { + if (!checkbox && isCheckboxInput(child)) { + checkbox = child; + return; + } + + if (!checkbox && isParagraphElement(child)) { + const paragraphChildren: ReactNode[] = []; + + Children.toArray(child.props.children).forEach((paragraphChild) => { + if (!checkbox && isCheckboxInput(paragraphChild)) { + checkbox = paragraphChild; + return; + } + paragraphChildren.push(paragraphChild); + }); + + if (checkbox) { + if (paragraphChildren.length > 0) { + content.push(cloneElement(child, undefined, ...paragraphChildren)); + } + return; + } + } + + content.push(child); + }); + + return { checkbox, content }; +}; + interface ListProps extends React.HTMLAttributes, ReactMarkdownProps { ordered?: boolean; children: React.ReactNode; @@ -46,17 +103,20 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS); if (isTaskListItem) { + const { checkbox, content } = splitTaskListItemChildren(children); + return (
  • [data-slot=checkbox]]:mt-1", - "[&>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", + "mt-0.5 min-w-0 leading-6 list-none grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2 [&>[data-slot=checkbox]]:mt-1", className, )} {...domProps} > - {children} + + {checkbox} +
    {content}
    +
  • ); } diff --git a/web/src/components/MemoContent/markdown/README.md b/web/src/components/MemoContent/markdown/README.md index ece9757a..71d6e3e0 100644 --- a/web/src/components/MemoContent/markdown/README.md +++ b/web/src/components/MemoContent/markdown/README.md @@ -14,5 +14,6 @@ GFM task lists are normalized before rendering by `remarkSplitMixedTaskLists`. - 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. +- `ListItem` uses a two-column grid: checkbox/control in the first column and a single task-body wrapper in the second. +- Task text, emphasis, links, tags, and nested content stay inside the body wrapper so inline markdown does not become separate grid items. +- Loose task items keep paragraph structure inside the task-body wrapper. diff --git a/web/tests/memo-content-list.test.tsx b/web/tests/memo-content-list.test.tsx index ab842b52..cb5addad 100644 --- a/web/tests/memo-content-list.test.tsx +++ b/web/tests/memo-content-list.test.tsx @@ -34,7 +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-start gap-x-2"); + expect(html).toContain("grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2"); + expect(html).toContain('
    pickup package
    '); expect(html).not.toMatch(/
  • { it("keeps nested task lists on their own row", () => { const html = renderListContent("- [ ] asdas\n - [ ] zzzz"); - expect(html).toContain("grid grid-cols-[auto_1fr] items-start gap-x-2"); - expect(html).toContain("[&>ul]:col-start-2"); + expect(html).toContain("grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2"); + expect(html).toContain('
    asdas'); expect(html).not.toContain("[&_ul.contains-task-list]:ml-6"); expect(html).toContain("zzzz"); }); @@ -58,10 +59,19 @@ describe("memo content lists", () => { 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).toMatch(/

  • \s*/); + expect(html).toContain('
    '); + expect(html).toContain("

    plan

    "); expect(html).toContain("

    keep details

    "); expect(html).toContain("zzzz"); }); + + it("keeps inline task markdown in the task body", () => { + const html = renderListContent("- [ ] Northern Lights in Iceland — booking this for winter, *finally*"); + + expect(html).toContain(''); + expect(html).toContain( + '
    Northern Lights in Iceland — booking this for winter, finally
    ', + ); + }); });