Skip to content

Commit 7154ce0

Browse files
committed
feat: implement markdown components for enhanced rendering
1 parent c0d6224 commit 7154ce0

File tree

16 files changed

+472
-354
lines changed

16 files changed

+472
-354
lines changed

web/src/components/MemoContent/CodeBlock.tsx

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { useAuth } from "@/contexts/AuthContext";
66
import { cn } from "@/lib/utils";
77
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
88
import { MermaidBlock } from "./MermaidBlock";
9+
import type { ReactMarkdownProps } from "./markdown/types";
910
import { extractCodeContent, extractLanguage } from "./utils";
1011

11-
interface CodeBlockProps {
12+
interface CodeBlockProps extends ReactMarkdownProps {
1213
children?: React.ReactNode;
1314
className?: string;
1415
}
1516

16-
export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) => {
17+
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
1718
const { userGeneralSetting } = useAuth();
1819
const [copied, setCopied] = useState(false);
1920

@@ -114,20 +115,41 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
114115
};
115116

116117
return (
117-
<pre className="relative">
118-
<div className="absolute right-2 leading-3 top-1.5 flex flex-row justify-end items-center gap-1 opacity-60 hover:opacity-80">
119-
<span className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-wider select-none">{language}</span>
118+
<pre className="relative my-3 rounded-lg border border-border bg-muted/30 overflow-hidden">
119+
{/* Header with language label and copy button */}
120+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-accent/30">
121+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide select-none">{language || "text"}</span>
120122
<button
121123
onClick={handleCopy}
122-
className={cn("rounded-md transition-all", "hover:bg-accent/50", copied ? "text-primary" : "text-muted-foreground")}
124+
className={cn(
125+
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium",
126+
"transition-all duration-200",
127+
"hover:bg-accent/80 active:scale-95",
128+
copied ? "text-primary bg-primary/10" : "text-muted-foreground bg-transparent",
129+
)}
123130
aria-label={copied ? "Copied" : "Copy code"}
124131
title={copied ? "Copied!" : "Copy code"}
125132
>
126-
{copied ? <CheckIcon className="w-3 h-3" /> : <CopyIcon className="w-3 h-3" />}
133+
{copied ? (
134+
<>
135+
<CheckIcon className="w-3.5 h-3.5" />
136+
<span>Copied</span>
137+
</>
138+
) : (
139+
<>
140+
<CopyIcon className="w-3.5 h-3.5" />
141+
<span>Copy</span>
142+
</>
143+
)}
127144
</button>
128145
</div>
129-
<div className={className} {...props}>
130-
<code className={`language-${language}`} dangerouslySetInnerHTML={{ __html: highlightedCode }} />
146+
147+
{/* Code content */}
148+
<div className="overflow-x-auto">
149+
<code
150+
className={cn("block px-3 py-2 text-sm leading-relaxed", `language-${language}`)}
151+
dangerouslySetInnerHTML={{ __html: highlightedCode }}
152+
/>
131153
</div>
132154
</pre>
133155
);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { cn } from "@/lib/utils";
2+
import type { ReactMarkdownProps } from "./markdown/types";
3+
4+
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
5+
children: React.ReactNode;
6+
}
7+
8+
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
9+
return (
10+
<div className="w-full overflow-x-auto rounded-lg border border-border my-4">
11+
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
12+
{children}
13+
</table>
14+
</div>
15+
);
16+
};
17+
18+
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
19+
children: React.ReactNode;
20+
}
21+
22+
export const TableHead = ({ children, className, node: _node, ...props }: TableHeadProps) => {
23+
return (
24+
<thead className={cn("bg-accent", className)} {...props}>
25+
{children}
26+
</thead>
27+
);
28+
};
29+
30+
interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
31+
children: React.ReactNode;
32+
}
33+
34+
export const TableBody = ({ children, className, node: _node, ...props }: TableBodyProps) => {
35+
return (
36+
<tbody className={cn("divide-y divide-border", className)} {...props}>
37+
{children}
38+
</tbody>
39+
);
40+
};
41+
42+
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement>, ReactMarkdownProps {
43+
children: React.ReactNode;
44+
}
45+
46+
export const TableRow = ({ children, className, node: _node, ...props }: TableRowProps) => {
47+
return (
48+
<tr className={cn("transition-colors hover:bg-muted/50", "even:bg-accent/50", className)} {...props}>
49+
{children}
50+
</tr>
51+
);
52+
};
53+
54+
interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
55+
children: React.ReactNode;
56+
}
57+
58+
export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
59+
return (
60+
<th
61+
className={cn(
62+
"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground",
63+
"border-b-2 border-border",
64+
className,
65+
)}
66+
{...props}
67+
>
68+
{children}
69+
</th>
70+
);
71+
};
72+
73+
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
74+
children: React.ReactNode;
75+
}
76+
77+
export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
78+
return (
79+
<td className={cn("px-4 py-3 text-left", className)} {...props}>
80+
{children}
81+
</td>
82+
);
83+
};

web/src/components/MemoContent/TaskListItem.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import type { Element } from "hast";
21
import { useRef } from "react";
32
import { Checkbox } from "@/components/ui/checkbox";
43
import { useUpdateMemo } from "@/hooks/useMemoQueries";
54
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
65
import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
6+
import type { ReactMarkdownProps } from "./markdown/types";
77

8-
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
9-
node?: Element; // AST node from react-markdown
8+
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement>, ReactMarkdownProps {
109
checked?: boolean;
1110
}
1211

13-
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => {
12+
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node, ...props }) => {
1413
const { memo } = useMemoViewContext();
1514
const { readonly } = useMemoViewDerived();
1615
const checkboxRef = useRef<HTMLButtonElement>(null);
@@ -35,14 +34,19 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props })
3534
if (taskIndexStr !== null) {
3635
taskIndex = parseInt(taskIndexStr);
3736
} else {
38-
// Fallback: Calculate index by counting ALL task list items in the memo
39-
// Find the markdown-content container by traversing up from the list item
40-
const container = listItem.closest(".markdown-content");
41-
if (!container) {
42-
return;
37+
// Fallback: Calculate index by counting task list items
38+
// Walk up to find the parent element with all task items
39+
let searchRoot = listItem.parentElement;
40+
while (searchRoot && !searchRoot.classList.contains("contains-task-list")) {
41+
searchRoot = searchRoot.parentElement;
4342
}
4443

45-
const allTaskItems = container.querySelectorAll("li.task-list-item");
44+
// If not found, search from the document root
45+
if (!searchRoot) {
46+
searchRoot = document.body;
47+
}
48+
49+
const allTaskItems = searchRoot.querySelectorAll("li.task-list-item");
4650
for (let i = 0; i < allTaskItems.length; i++) {
4751
if (allTaskItems[i] === listItem) {
4852
taskIndex = i;

web/src/components/MemoContent/index.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { CodeBlock } from "./CodeBlock";
1717
import { isTagNode, isTaskListItemNode } from "./ConditionalComponent";
1818
import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
1919
import { useCompactLabel, useCompactMode } from "./hooks";
20+
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
21+
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
2022
import { Tag } from "./Tag";
2123
import { TaskListItem } from "./TaskListItem";
2224
import type { MemoContentProps } from "./types";
@@ -37,7 +39,7 @@ const MemoContent = (props: MemoContentProps) => {
3739
<div
3840
ref={memoContentContainerRef}
3941
className={cn(
40-
"markdown-content relative w-full max-w-full wrap-break-word text-base leading-6",
42+
"relative w-full max-w-full wrap-break-word text-base leading-6",
4143
showCompactMode === "ALL" && `max-h-[${COMPACT_MODE_CONFIG.maxHeightVh}vh] overflow-hidden`,
4244
contentClassName,
4345
)}
@@ -62,12 +64,38 @@ const MemoContent = (props: MemoContentProps) => {
6264
}
6365
return <span {...rest} />;
6466
}) as React.ComponentType<React.ComponentProps<"span">>,
65-
pre: CodeBlock,
66-
a: ({ href, children, ...aProps }) => (
67-
<a href={href} target="_blank" rel="noopener noreferrer" {...aProps}>
67+
// Headings
68+
h1: ({ children }) => <Heading level={1}>{children}</Heading>,
69+
h2: ({ children }) => <Heading level={2}>{children}</Heading>,
70+
h3: ({ children }) => <Heading level={3}>{children}</Heading>,
71+
h4: ({ children }) => <Heading level={4}>{children}</Heading>,
72+
h5: ({ children }) => <Heading level={5}>{children}</Heading>,
73+
h6: ({ children }) => <Heading level={6}>{children}</Heading>,
74+
// Block elements
75+
p: ({ children }) => <Paragraph>{children}</Paragraph>,
76+
blockquote: ({ children }) => <Blockquote>{children}</Blockquote>,
77+
hr: () => <HorizontalRule />,
78+
// Lists
79+
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
80+
ol: ({ children, ...props }) => (
81+
<List ordered {...props}>
6882
{children}
69-
</a>
83+
</List>
7084
),
85+
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
86+
// Inline elements
87+
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
88+
code: ({ children }) => <InlineCode>{children}</InlineCode>,
89+
img: ({ ...props }) => <Image {...props} />,
90+
// Code blocks
91+
pre: CodeBlock,
92+
// Tables
93+
table: ({ children }) => <Table>{children}</Table>,
94+
thead: ({ children }) => <TableHead>{children}</TableHead>,
95+
tbody: ({ children }) => <TableBody>{children}</TableBody>,
96+
tr: ({ children }) => <TableRow>{children}</TableRow>,
97+
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
98+
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
7199
}}
72100
>
73101
{content}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { cn } from "@/lib/utils";
2+
import type { ReactMarkdownProps } from "./types";
3+
4+
interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement>, ReactMarkdownProps {
5+
children: React.ReactNode;
6+
}
7+
8+
/**
9+
* Blockquote component with left border accent
10+
*/
11+
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
12+
return (
13+
<blockquote className={cn("my-0 mb-2 border-l-4 border-border pl-3 text-muted-foreground", className)} {...props}>
14+
{children}
15+
</blockquote>
16+
);
17+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { cn } from "@/lib/utils";
2+
import type { ReactMarkdownProps } from "./types";
3+
4+
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement>, ReactMarkdownProps {
5+
level: 1 | 2 | 3 | 4 | 5 | 6;
6+
children: React.ReactNode;
7+
}
8+
9+
/**
10+
* Heading component for h1-h6 elements
11+
* Renders semantic heading levels with consistent styling
12+
*/
13+
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
14+
const Component = `h${level}` as const;
15+
16+
const levelClasses = {
17+
1: "text-3xl font-bold border-b border-border pb-1",
18+
2: "text-2xl border-b border-border pb-1",
19+
3: "text-xl",
20+
4: "text-base",
21+
5: "text-sm",
22+
6: "text-sm text-muted-foreground",
23+
};
24+
25+
return (
26+
<Component className={cn("mt-3 mb-2 font-semibold leading-tight", levelClasses[level], className)} {...props}>
27+
{children}
28+
</Component>
29+
);
30+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { cn } from "@/lib/utils";
2+
import type { ReactMarkdownProps } from "./types";
3+
4+
interface HorizontalRuleProps extends React.HTMLAttributes<HTMLHRElement>, ReactMarkdownProps {}
5+
6+
/**
7+
* Horizontal rule separator
8+
*/
9+
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
10+
return <hr className={cn("my-2 h-0 border-0 border-b border-border", className)} {...props} />;
11+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { cn } from "@/lib/utils";
2+
import type { ReactMarkdownProps } from "./types";
3+
4+
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement>, ReactMarkdownProps {}
5+
6+
/**
7+
* Image component for markdown images
8+
* Responsive with rounded corners
9+
*/
10+
export const Image = ({ className, alt, node: _node, ...props }: ImageProps) => {
11+
return <img className={cn("max-w-full h-auto rounded-lg my-2", className)} alt={alt} {...props} />;
12+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { cn } from "@/lib/utils";
2+
import type { ReactMarkdownProps } from "./types";
3+
4+
interface InlineCodeProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
5+
children: React.ReactNode;
6+
}
7+
8+
/**
9+
* Inline code component with background and monospace font
10+
*/
11+
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
12+
return (
13+
<code className={cn("font-mono text-sm bg-muted px-1 py-0.5 rounded", className)} {...props}>
14+
{children}
15+
</code>
16+
);
17+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { cn } from "@/lib/utils";
2+
import type { ReactMarkdownProps } from "./types";
3+
4+
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
5+
children: React.ReactNode;
6+
}
7+
8+
/**
9+
* Link component for external links
10+
* Opens in new tab with security attributes
11+
*/
12+
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
13+
return (
14+
<a
15+
href={href}
16+
target="_blank"
17+
rel="noopener noreferrer"
18+
className={cn("text-primary underline transition-opacity hover:opacity-80", className)}
19+
{...props}
20+
>
21+
{children}
22+
</a>
23+
);
24+
};

0 commit comments

Comments
 (0)