Skip to content

Commit e046b58

Browse files
committed
refactor: extract all markdown components
1 parent 421dc74 commit e046b58

File tree

3 files changed

+174
-162
lines changed

3 files changed

+174
-162
lines changed

src/bubble/hooks.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useEffect, useState } from "react";
2+
3+
export const useTheme = () => {
4+
const [isDark, setDark] = useState(false);
5+
6+
useEffect(() => {
7+
const checkDarkMode = () => {
8+
setDark(document.documentElement.classList.contains("dark"));
9+
};
10+
checkDarkMode();
11+
12+
const observer = new MutationObserver(checkDarkMode);
13+
observer.observe(document.documentElement, {
14+
attributes: true,
15+
attributeFilter: ["class"],
16+
});
17+
18+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
19+
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
20+
setDark(event.matches);
21+
};
22+
mediaQuery.addEventListener("change", handleSystemThemeChange);
23+
24+
return () => {
25+
observer.disconnect();
26+
mediaQuery.removeEventListener("change", handleSystemThemeChange);
27+
};
28+
}, []);
29+
30+
return { isDark };
31+
};

src/bubble.tsx renamed to src/bubble/index.tsx

Lines changed: 12 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -3,47 +3,13 @@ import "./tailwind.css";
33

44
import clsx from "clsx";
55
import type React from "react";
6-
import { memo, useCallback, useEffect, useRef, useState } from "react";
6+
import { memo, useCallback, useEffect, useRef } from "react";
77
import Markdown from "react-markdown";
8-
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
9-
import {
10-
oneLight,
11-
vscDarkPlus,
12-
} from "react-syntax-highlighter/dist/esm/styles/prism";
138
import remarkGfm from "remark-gfm";
149
import remarkMath from "remark-math";
1510
import { twMerge } from "tailwind-merge";
16-
import type { MessageParam } from "./utils";
17-
18-
const useTheme = () => {
19-
const [isDark, setDark] = useState(false);
20-
21-
useEffect(() => {
22-
const checkDarkMode = () => {
23-
setDark(document.documentElement.classList.contains("dark"));
24-
};
25-
checkDarkMode();
26-
27-
const observer = new MutationObserver(checkDarkMode);
28-
observer.observe(document.documentElement, {
29-
attributes: true,
30-
attributeFilter: ["class"],
31-
});
32-
33-
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
34-
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
35-
setDark(event.matches);
36-
};
37-
mediaQuery.addEventListener("change", handleSystemThemeChange);
38-
39-
return () => {
40-
observer.disconnect();
41-
mediaQuery.removeEventListener("change", handleSystemThemeChange);
42-
};
43-
}, []);
44-
45-
return { isDark };
46-
};
11+
import type { MessageParam } from "../utils";
12+
import { BlockQuote, CodeBlock, Heading, Link } from "./markdown";
4713

4814
const bubbleVariants = cva(
4915
"flex flex-col gap-1 justify-center rounded-lg dark:text-gray-200 text-gray-800 max-w-full overflow-x-auto",
@@ -111,7 +77,6 @@ export function Bubble({
11177
isPending = false,
11278
...props
11379
}: BubbleProps) {
114-
const { isDark } = useTheme();
11580

11681
const defaultPending = (
11782
<div className="flex items-center space-x-1 py-1">
@@ -149,130 +114,15 @@ export function Bubble({
149114
<Markdown
150115
remarkPlugins={[remarkGfm, remarkMath]}
151116
components={{
152-
h1(props) {
153-
const { children, className, node: _node, ...rest } = props;
154-
return (
155-
<h1
156-
{...rest}
157-
className={clsx("my-3 text-2xl font-bold", className)}
158-
>
159-
{children}
160-
</h1>
161-
);
162-
},
163-
h2(props) {
164-
const { children, className, node: _node, ...rest } = props;
165-
return (
166-
<h2
167-
{...rest}
168-
className={clsx("my-2 text-xl font-bold", className)}
169-
>
170-
{children}
171-
</h2>
172-
);
173-
},
174-
h3(props) {
175-
const { children, className, node: _node, ...rest } = props;
176-
return (
177-
<h3
178-
{...rest}
179-
className={clsx("my-1 text-lg font-bold", className)}
180-
>
181-
{children}
182-
</h3>
183-
);
184-
},
185-
code(props) {
186-
const { children, className, ref: _ref, ...rest } = props;
187-
const match = /language-(\w+)/.exec(className || "");
188-
189-
const [copied, setCopied] = useState(false);
190-
const handleCopy = () => {
191-
navigator.clipboard.writeText(String(children).replace(/\n$/, ''));
192-
setCopied(true);
193-
setTimeout(() => setCopied(false), 2000);
194-
};
195-
196-
return match ? (
197-
<div
198-
className={clsx(
199-
"w-full overflow-x-auto rounded-lg",
200-
"bg-gray-50 dark:bg-gray-800",
201-
)}
202-
>
203-
<div className="inline-flex w-full justify-between bg-gray-100 p-2">
204-
<div className="px-2 py-1 text-xs text-gray-900 dark:text-gray-400">
205-
{match[1]}
206-
</div>
207-
<div
208-
className="px-2 py-1 text-xs text-gray-900 dark:text-gray-400 cursor-pointer"
209-
onClick={handleCopy}
210-
>
211-
{copied ? "Copied" : "Copy"}
212-
</div>
213-
</div>
214-
<SyntaxHighlighter
215-
{...rest}
216-
PreTag="div"
217-
language={match[1]}
218-
style={isDark ? vscDarkPlus : oneLight}
219-
customStyle={{
220-
background: "transparent",
221-
margin: 0,
222-
padding: "1rem",
223-
borderRadius: "0.5rem",
224-
overflowX: "auto",
225-
}}
226-
codeTagProps={{
227-
style: {
228-
fontFamily: "monospace",
229-
fontSize: "0.875rem",
230-
},
231-
}}
232-
>
233-
{String(children).replace(/\n$/, "")}
234-
</SyntaxHighlighter>
235-
</div>
236-
) : (
237-
<code
238-
{...rest}
239-
className={clsx(
240-
"rounded-md px-1 py-0.5 text-[85%]",
241-
"bg-gray-100 dark:bg-gray-800",
242-
)}
243-
>
244-
{children}
245-
</code>
246-
);
247-
},
248-
blockquote(props) {
249-
const { children, className, ...rest } = props;
250-
return (
251-
<blockquote
252-
{...rest}
253-
className={clsx(
254-
"border-l-4 border-gray-300 pl-4 italic",
255-
className,
256-
)}
257-
>
258-
{children}
259-
</blockquote>
260-
);
261-
},
262-
a(props) {
263-
const { children, className, ref: _ref, ...rest } = props;
264-
return (
265-
<a
266-
{...rest}
267-
className={clsx(
268-
"text-blue-600 dark:text-blue-400 hover:underline underline-offset-1",
269-
className,
270-
)}
271-
>
272-
{children}
273-
</a>
274-
);
275-
},
117+
a: Link,
118+
code: CodeBlock,
119+
blockquote: BlockQuote,
120+
h1: (props) => <Heading {...props} level={1} />,
121+
h2: (props) => <Heading {...props} level={2} />,
122+
h3: (props) => <Heading {...props} level={3} />,
123+
h4: (props) => <Heading {...props} level={4} />,
124+
h5: (props) => <Heading {...props} level={5} />,
125+
h6: (props) => <Heading {...props} level={6} />,
276126
}}
277127
>
278128
{text}

src/bubble/markdown.tsx

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import clsx from "clsx";
2+
import { useCallback, useState } from "react";
3+
import SyntaxHighlighter from "react-syntax-highlighter";
4+
import { useTheme } from "./hooks";
5+
import {
6+
oneLight,
7+
vscDarkPlus,
8+
} from "react-syntax-highlighter/dist/esm/styles/prism";
9+
10+
export interface HeadingProps extends React.ComponentProps<"h1"> {
11+
level: 1 | 2 | 3 | 4 | 5 | 6;
12+
}
13+
14+
const headingVariant = {
15+
1: "text-2xl font-bold my-3",
16+
2: "text-xl font-bold my-2",
17+
3: "text-lg font-bold my-1",
18+
4: "text-md font-bold my-1",
19+
5: "text-base font-bold",
20+
6: "text-base font-bold",
21+
};
22+
23+
export function Heading({ children, className, ...rest }: HeadingProps) {
24+
const { level } = rest;
25+
return (
26+
<h1 {...rest} className={clsx(headingVariant[level], className)}>
27+
{children}
28+
</h1>
29+
);
30+
}
31+
32+
export interface CodeBlockProps extends React.ComponentProps<"code"> {}
33+
34+
export function CodeBlock({
35+
children,
36+
className,
37+
ref: _ref,
38+
...rest
39+
}: CodeBlockProps) {
40+
const isDark = useTheme();
41+
const match = /language-(\w+)/.exec(className || "");
42+
43+
const [copied, setCopied] = useState<boolean>(false);
44+
const handleCopy = useCallback(() => {
45+
navigator.clipboard.writeText(String(children).replace(/\n$/, ""));
46+
setCopied(true);
47+
setTimeout(() => setCopied(false), 2000);
48+
}, [children]);
49+
50+
return match ? (
51+
<div
52+
className={clsx(
53+
"w-full overflow-x-auto rounded-lg",
54+
"bg-gray-50 dark:bg-gray-800"
55+
)}
56+
>
57+
<div className="inline-flex w-full justify-between bg-gray-100 p-2">
58+
<div className="px-2 py-1 text-xs text-gray-900 dark:text-gray-400">
59+
{match[1]}
60+
</div>
61+
<div
62+
className="px-2 py-1 text-xs text-gray-900 dark:text-gray-400 cursor-pointer"
63+
onClick={handleCopy}
64+
>
65+
{copied ? "Copied" : "Copy"}
66+
</div>
67+
</div>
68+
<SyntaxHighlighter
69+
{...rest}
70+
PreTag="div"
71+
language={match[1]}
72+
style={isDark ? vscDarkPlus : oneLight}
73+
customStyle={{
74+
background: "transparent",
75+
margin: 0,
76+
padding: "1rem",
77+
borderRadius: "0.5rem",
78+
overflowX: "auto",
79+
}}
80+
codeTagProps={{
81+
style: {
82+
fontFamily: "monospace",
83+
fontSize: "0.875rem",
84+
},
85+
}}
86+
>
87+
{String(children).replace(/\n$/, "")}
88+
</SyntaxHighlighter>
89+
</div>
90+
) : (
91+
<code
92+
{...rest}
93+
className={clsx(
94+
"rounded-md px-1 py-0.5 text-[85%]",
95+
"bg-gray-100 dark:bg-gray-800"
96+
)}
97+
>
98+
{children}
99+
</code>
100+
);
101+
}
102+
103+
export interface BlockQuoteProps extends React.ComponentProps<"blockquote"> {}
104+
105+
export function BlockQuote({ children, className, ...rest }: BlockQuoteProps) {
106+
return (
107+
<blockquote
108+
{...rest}
109+
className={clsx("border-l-4 border-gray-300 pl-4 italic", className)}
110+
>
111+
{children}
112+
</blockquote>
113+
);
114+
}
115+
116+
export interface LinkProps extends React.ComponentProps<"a"> {}
117+
118+
export function Link({ children, className, ...rest }: LinkProps) {
119+
return (
120+
<a
121+
className={clsx(
122+
"text-blue-600 dark:text-blue-400 hover:underline underline-offset-1",
123+
className
124+
)}
125+
target="_blank"
126+
{...rest}
127+
>
128+
{children}
129+
</a>
130+
);
131+
}

0 commit comments

Comments
 (0)