diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 484911e7..232d434b 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -14,6 +14,7 @@ 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"; @@ -79,7 +80,16 @@ const MemoContent = (props: MemoContentProps) => { onDoubleClick={onDoubleClick} > typeof item.checked === "boolean"; + +const splitMixedList = (list: List): List[] => { + const hasTaskItem = list.children.some(isTaskListItem); + const hasRegularItem = list.children.some((item) => !isTaskListItem(item)); + + if (!hasTaskItem || !hasRegularItem) { + return [list]; + } + + const groups: Array<{ isTaskGroup: boolean; items: ListItem[] }> = []; + for (const item of list.children) { + const isTaskGroup = isTaskListItem(item); + const previousGroup = groups.at(-1); + + if (previousGroup && previousGroup.isTaskGroup === isTaskGroup) { + previousGroup.items.push(item); + } else { + groups.push({ isTaskGroup, items: [item] }); + } + } + + return groups.map(({ isTaskGroup, items }) => ({ + ...list, + children: isTaskGroup ? items : items.map((item) => ({ ...item, spread: false })), + spread: isTaskGroup ? list.spread : false, + })); +}; + +const splitMixedTaskListsInParent = (parent: Parent): void => { + for (let index = 0; index < parent.children.length; index++) { + const child = parent.children[index]; + + if ("children" in child && Array.isArray(child.children)) { + splitMixedTaskListsInParent(child as Parent); + } + + if (child.type !== "list") { + continue; + } + + const splitLists = splitMixedList(child as List); + if (splitLists.length > 1) { + parent.children.splice(index, 1, ...splitLists); + index += splitLists.length - 1; + } + } +}; + +export const remarkSplitMixedTaskLists = () => { + return (tree: Root) => { + splitMixedTaskListsInParent(tree); + }; +}; diff --git a/web/tests/memo-content-list.test.tsx b/web/tests/memo-content-list.test.tsx new file mode 100644 index 00000000..cfc6ecb4 --- /dev/null +++ b/web/tests/memo-content-list.test.tsx @@ -0,0 +1,45 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { List, ListItem } from "@/components/MemoContent/markdown"; +import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "@/components/MemoContent/constants"; +import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists"; +import { describe, expect, it } from "vitest"; + +const renderListContent = (content: string): string => + renderToStaticMarkup( + {children}, + li: ({ children, ...props }) => {children}, + }} + > + {content} + , + ); + +describe("memo content lists", () => { + it("keeps bullets on regular items in mixed task and bullet lists", () => { + const html = renderListContent("- [ ] pickup package\n- [ ] library returns\n\n- milk\n- eggs\n- bread"); + const listOpenTags = html.match(/