diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8bea3cc92b0..c4f699ea69b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
## vNEXT (not yet published)
+### `@liveblocks/react-ui`
+
+- Improve Markdown lists in `AiChat`: better spacing and support for arbitrary
+ starting numbers in ordered lists. (e.g. `3.` instead of `1.`)
+
## v3.2.0
### `@liveblocks/react-ui`
diff --git a/docs/pages/api-reference/liveblocks-react-ui.mdx b/docs/pages/api-reference/liveblocks-react-ui.mdx
index 65bb9dde8ae..a40a6267c6f 100644
--- a/docs/pages/api-reference/liveblocks-react-ui.mdx
+++ b/docs/pages/api-reference/liveblocks-react-ui.mdx
@@ -522,7 +522,7 @@ Override specific parts of `AiChat` with custom components.
ReactNode`}
+ detailedType={`({ type: "ordered" | "unordered", items: { checked?: boolean, children: ReactNode }[], start?: number }) => ReactNode`}
>
The component used to render lists.
diff --git a/e2e/next-ai-kitchen-sink/app/styles/page.tsx b/e2e/next-ai-kitchen-sink/app/styles/page.tsx
index 27f1c9470fb..c88b27cf8c2 100644
--- a/e2e/next-ai-kitchen-sink/app/styles/page.tsx
+++ b/e2e/next-ai-kitchen-sink/app/styles/page.tsx
@@ -344,6 +344,42 @@ Just some plain text
---
+1. A numbered list item
+- A "nested" list item
+- Another "nested" list item
+
+2. Another numbered list item
+- A "nested" list item
+- Another "nested" list item
+
+3. Yet another numbered list item
+- A "nested" list item
+- Another "nested" list item
+
+---
+
+1. A numbered list item
+
+\`\`\`
+const a = 2;
+\`\`\`
+
+2. Another numbered list item
+
+> A quote.
+
+3. Yet another numbered list item
+
+A paragraph.
+
+---
+
+1. A numbered list item
+1. Another numbered list item
+1. Yet another numbered list item
+
+---
+
The abbreviation for HyperText Markup Language is HTML.
Press Ctrl + C to copy.
diff --git a/packages/liveblocks-react-ui/src/primitives/Markdown.tsx b/packages/liveblocks-react-ui/src/primitives/Markdown.tsx
index fbf9ea73223..ec64ddaacc2 100644
--- a/packages/liveblocks-react-ui/src/primitives/Markdown.tsx
+++ b/packages/liveblocks-react-ui/src/primitives/Markdown.tsx
@@ -1,4 +1,4 @@
-import { assertNever, sanitizeUrl } from "@liveblocks/core";
+import { assertNever, type Relax, sanitizeUrl } from "@liveblocks/core";
import { Slot } from "@radix-ui/react-slot";
import { Lexer, type MarkedToken, type Token, type Tokens } from "marked";
import {
@@ -159,10 +159,10 @@ export type MarkdownComponents = {
* ```tsx
* {
+ * List: ({ type, items, start }) => {
* const List = type === "ordered" ? "ol" : "ul";
* return (
- *
+ *
* {items.map((item, index) => (
*
* {item.checked !== undefined && (
@@ -257,8 +257,18 @@ interface MarkdownComponentsListItem {
children: ReactNode;
}
-export interface MarkdownComponentsListProps {
- type: "ordered" | "unordered";
+export type MarkdownComponentsListProps = Relax<
+ MarkdownComponentsOrderedListProps | MarkdownComponentsUnorderedListProps
+>;
+
+interface MarkdownComponentsOrderedListProps {
+ type: "ordered";
+ items: MarkdownComponentsListItem[];
+ start: number;
+}
+
+interface MarkdownComponentsUnorderedListProps {
+ type: "unordered";
items: MarkdownComponentsListItem[];
}
@@ -368,11 +378,11 @@ const defaultComponents: MarkdownComponents = {
);
},
- List: ({ type, items }) => {
+ List: ({ type, items, start }) => {
const List = type === "ordered" ? "ol" : "ul";
return (
-
+
{items.map((item, index) => (
{item.checked !== undefined && (
@@ -586,9 +596,11 @@ export function MarkdownToken({
};
});
- return (
-
- );
+ const props: MarkdownComponentsListProps = token.ordered
+ ? { type: "ordered", items, start: token.start || 1 }
+ : { type: "unordered", items };
+
+ return
;
}
case "table": {
diff --git a/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx b/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx
index 3998c1687e5..64cc6c8ef8e 100644
--- a/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx
+++ b/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx
@@ -319,6 +319,95 @@ describe("Markdown", () => {
);
},
},
+ {
+ description: "numbered lists with arbitrary start indices",
+ content: dedent`
+ 1. A numbered list item
+ - A "nested" list item
+ - Another "nested" list item
+
+ 2. Another numbered list item
+ - A "nested" list item
+ - Another "nested" list item
+
+ 3. Yet another numbered list item
+ - A "nested" list item
+ - Another "nested" list item
+
+ ---
+
+ 1. A numbered list item
+
+ \`\`\`
+ const a = 2;
+ \`\`\`
+
+ 2. Another numbered list item
+
+ > A quote.
+
+ 3. Yet another numbered list item
+
+ A paragraph.
+
+ ---
+
+ 1. A numbered list item
+ 1. Another numbered list item
+ 1. Yet another numbered list item
+ `,
+ assertions: (element) => {
+ const listItems = element.querySelectorAll("li");
+ expect(listItems).toHaveLength(15);
+
+ // 1. A numbered list item
+ // - A "nested" list item
+ // - Another "nested" list item
+ //
+ // 2. Another numbered list item
+ // - A "nested" list item
+ // - Another "nested" list item
+ //
+ // 3. Yet another numbered list item
+ // - A "nested" list item
+ // - Another "nested" list item
+ const firstListFirstItem = listItems[0]?.parentElement;
+ expect(firstListFirstItem).not.toHaveAttribute("start");
+ const firstListSecondItem = listItems[3]?.parentElement;
+ expect(firstListSecondItem).toHaveAttribute("start", "2");
+ const firstListThirdItem = listItems[6]?.parentElement;
+ expect(firstListThirdItem).toHaveAttribute("start", "3");
+
+ // 1. A numbered list item
+ //
+ // \`\`\`
+ // const a = 2;
+ // \`\`\`
+ //
+ // 2. Another numbered list item
+ //
+ // > A quote.
+ //
+ // 3. Yet another numbered list item
+ //
+ // A paragraph.
+ const secondListFirstItem = listItems[9]?.parentElement;
+ expect(secondListFirstItem).not.toHaveAttribute("start");
+ const secondListSecondItem = listItems[10]?.parentElement;
+ expect(secondListSecondItem).toHaveAttribute("start", "2");
+ const secondListThirdItem = listItems[11]?.parentElement;
+ expect(secondListThirdItem).toHaveAttribute("start", "3");
+
+ // 1. A numbered list item
+ // 1. Another numbered list item
+ // 1. Yet another numbered list item
+ const thirdList = document.querySelector("ol:last-of-type");
+ expect(listItems[12]?.parentElement).toBe(thirdList);
+ expect(listItems[13]?.parentElement).toBe(thirdList);
+ expect(listItems[14]?.parentElement).toBe(thirdList);
+ expect(thirdList).not.toHaveAttribute("start");
+ },
+ },
{
description: "blockquotes",
content: dedent`
@@ -664,11 +753,11 @@ describe("Markdown", () => {
+ [x] Yet another list item
`,
components: {
- List: ({ items, type }) => {
+ List: ({ items, type, start }) => {
const List = type === "ordered" ? "ol" : "ul";
return (
-
+
{items.map((item, index) => (
{item.checked !== undefined && (
diff --git a/packages/liveblocks-react-ui/src/styles/index.css b/packages/liveblocks-react-ui/src/styles/index.css
index d3033b4afc0..35f1bde236e 100644
--- a/packages/liveblocks-react-ui/src/styles/index.css
+++ b/packages/liveblocks-react-ui/src/styles/index.css
@@ -604,7 +604,7 @@
flex-direction: column;
gap: 0.25em;
margin-block: 0.75em;
- padding-inline-start: 1.5em;
+ padding-inline-start: 1.125em;
list-style-position: outside;
:where(ol, ul) {