|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { Button } from "@/components/ui/button"; |
4 | | -import { cn } from "@/lib/utils"; |
5 | 3 | import { CheckIcon, CopyIcon } from "lucide-react"; |
6 | 4 | import { |
7 | | - type ComponentProps, |
8 | | - createContext, |
9 | | - type HTMLAttributes, |
10 | | - useContext, |
11 | | - useEffect, |
12 | | - useRef, |
13 | | - useState, |
| 5 | + type ComponentProps, |
| 6 | + createContext, |
| 7 | + type HTMLAttributes, |
| 8 | + useContext, |
| 9 | + useEffect, |
| 10 | + useRef, |
| 11 | + useState, |
14 | 12 | } from "react"; |
15 | 13 | import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; |
| 14 | +import { Button } from "@/components/ui/button"; |
| 15 | +import { cn } from "@/lib/utils"; |
16 | 16 |
|
17 | 17 | type CodeBlockProps = HTMLAttributes<HTMLDivElement> & { |
18 | | - code: string; |
19 | | - language: BundledLanguage; |
20 | | - showLineNumbers?: boolean; |
| 18 | + code: string; |
| 19 | + language: BundledLanguage; |
| 20 | + showLineNumbers?: boolean; |
21 | 21 | }; |
22 | 22 |
|
23 | 23 | type CodeBlockContextType = { |
24 | | - code: string; |
| 24 | + code: string; |
25 | 25 | }; |
26 | 26 |
|
27 | 27 | const CodeBlockContext = createContext<CodeBlockContextType>({ |
28 | | - code: "", |
| 28 | + code: "", |
29 | 29 | }); |
30 | 30 |
|
31 | 31 | const lineNumberTransformer: ShikiTransformer = { |
32 | | - name: "line-numbers", |
33 | | - line(node, line) { |
34 | | - node.children.unshift({ |
35 | | - type: "element", |
36 | | - tagName: "span", |
37 | | - properties: { |
38 | | - className: [ |
39 | | - "inline-block", |
40 | | - "min-w-10", |
41 | | - "mr-4", |
42 | | - "text-right", |
43 | | - "select-none", |
44 | | - "text-muted-foreground", |
45 | | - ], |
46 | | - }, |
47 | | - children: [{ type: "text", value: String(line) }], |
48 | | - }); |
49 | | - }, |
| 32 | + name: "line-numbers", |
| 33 | + line(node, line) { |
| 34 | + node.children.unshift({ |
| 35 | + type: "element", |
| 36 | + tagName: "span", |
| 37 | + properties: { |
| 38 | + className: [ |
| 39 | + "inline-block", |
| 40 | + "min-w-10", |
| 41 | + "mr-4", |
| 42 | + "text-right", |
| 43 | + "select-none", |
| 44 | + "text-muted-foreground", |
| 45 | + ], |
| 46 | + }, |
| 47 | + children: [{ type: "text", value: String(line) }], |
| 48 | + }); |
| 49 | + }, |
50 | 50 | }; |
51 | 51 |
|
52 | 52 | export async function highlightCode( |
53 | | - code: string, |
54 | | - language: BundledLanguage, |
55 | | - showLineNumbers = false |
| 53 | + code: string, |
| 54 | + language: BundledLanguage, |
| 55 | + showLineNumbers = false |
56 | 56 | ) { |
57 | | - const transformers: ShikiTransformer[] = showLineNumbers |
58 | | - ? [lineNumberTransformer] |
59 | | - : []; |
60 | | - |
61 | | - return await Promise.all([ |
62 | | - codeToHtml(code, { |
63 | | - lang: language, |
64 | | - theme: "one-light", |
65 | | - transformers, |
66 | | - }), |
67 | | - codeToHtml(code, { |
68 | | - lang: language, |
69 | | - theme: "one-dark-pro", |
70 | | - transformers, |
71 | | - }), |
72 | | - ]); |
| 57 | + const transformers: ShikiTransformer[] = showLineNumbers |
| 58 | + ? [lineNumberTransformer] |
| 59 | + : []; |
| 60 | + |
| 61 | + return await Promise.all([ |
| 62 | + codeToHtml(code, { |
| 63 | + lang: language, |
| 64 | + theme: "one-light", |
| 65 | + transformers, |
| 66 | + }), |
| 67 | + codeToHtml(code, { |
| 68 | + lang: language, |
| 69 | + theme: "one-dark-pro", |
| 70 | + transformers, |
| 71 | + }), |
| 72 | + ]); |
73 | 73 | } |
74 | 74 |
|
75 | 75 | export const CodeBlock = ({ |
76 | | - code, |
77 | | - language, |
78 | | - showLineNumbers = false, |
79 | | - className, |
80 | | - children, |
81 | | - ...props |
| 76 | + code, |
| 77 | + language, |
| 78 | + showLineNumbers = false, |
| 79 | + className, |
| 80 | + children, |
| 81 | + ...props |
82 | 82 | }: CodeBlockProps) => { |
83 | | - const [html, setHtml] = useState<string>(""); |
84 | | - const [darkHtml, setDarkHtml] = useState<string>(""); |
85 | | - const mounted = useRef(false); |
86 | | - |
87 | | - useEffect(() => { |
88 | | - highlightCode(code, language, showLineNumbers).then(([light, dark]) => { |
89 | | - if (!mounted.current) { |
90 | | - setHtml(light); |
91 | | - setDarkHtml(dark); |
92 | | - mounted.current = true; |
93 | | - } |
94 | | - }); |
95 | | - |
96 | | - return () => { |
97 | | - mounted.current = false; |
98 | | - }; |
99 | | - }, [code, language, showLineNumbers]); |
100 | | - |
101 | | - return ( |
102 | | - <CodeBlockContext.Provider value={{ code }}> |
103 | | - <div |
104 | | - className={cn( |
105 | | - "group relative w-full overflow-hidden rounded-md border bg-background text-foreground", |
106 | | - className |
107 | | - )} |
108 | | - {...props} |
109 | | - > |
110 | | - <div className="relative"> |
111 | | - <div |
112 | | - className="overflow-hidden dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm" |
113 | | - // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." |
114 | | - dangerouslySetInnerHTML={{ __html: html }} |
115 | | - /> |
116 | | - <div |
117 | | - className="hidden overflow-hidden dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm" |
118 | | - // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." |
119 | | - dangerouslySetInnerHTML={{ __html: darkHtml }} |
120 | | - /> |
121 | | - {children && ( |
122 | | - <div className="absolute top-2 right-2 flex items-center gap-2"> |
123 | | - {children} |
124 | | - </div> |
125 | | - )} |
126 | | - </div> |
127 | | - </div> |
128 | | - </CodeBlockContext.Provider> |
129 | | - ); |
| 83 | + const [html, setHtml] = useState<string>(""); |
| 84 | + const [darkHtml, setDarkHtml] = useState<string>(""); |
| 85 | + const mounted = useRef(false); |
| 86 | + |
| 87 | + useEffect(() => { |
| 88 | + highlightCode(code, language, showLineNumbers).then(([light, dark]) => { |
| 89 | + if (!mounted.current) { |
| 90 | + setHtml(light); |
| 91 | + setDarkHtml(dark); |
| 92 | + mounted.current = true; |
| 93 | + } |
| 94 | + }); |
| 95 | + |
| 96 | + return () => { |
| 97 | + mounted.current = false; |
| 98 | + }; |
| 99 | + }, [code, language, showLineNumbers]); |
| 100 | + |
| 101 | + return ( |
| 102 | + <CodeBlockContext.Provider value={{ code }}> |
| 103 | + <div |
| 104 | + className={cn( |
| 105 | + "group relative w-full overflow-hidden rounded-md border bg-background text-foreground", |
| 106 | + className |
| 107 | + )} |
| 108 | + {...props} |
| 109 | + > |
| 110 | + <div className="relative"> |
| 111 | + <div |
| 112 | + className="overflow-hidden dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm" |
| 113 | + // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." |
| 114 | + dangerouslySetInnerHTML={{ __html: html }} |
| 115 | + /> |
| 116 | + <div |
| 117 | + className="hidden overflow-hidden dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm" |
| 118 | + // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." |
| 119 | + dangerouslySetInnerHTML={{ __html: darkHtml }} |
| 120 | + /> |
| 121 | + {children && ( |
| 122 | + <div className="absolute top-2 right-2 flex items-center gap-2"> |
| 123 | + {children} |
| 124 | + </div> |
| 125 | + )} |
| 126 | + </div> |
| 127 | + </div> |
| 128 | + </CodeBlockContext.Provider> |
| 129 | + ); |
130 | 130 | }; |
131 | 131 |
|
132 | 132 | export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & { |
133 | | - onCopy?: () => void; |
134 | | - onError?: (error: Error) => void; |
135 | | - timeout?: number; |
| 133 | + onCopy?: () => void; |
| 134 | + onError?: (error: Error) => void; |
| 135 | + timeout?: number; |
136 | 136 | }; |
137 | 137 |
|
138 | 138 | export const CodeBlockCopyButton = ({ |
139 | | - onCopy, |
140 | | - onError, |
141 | | - timeout = 2000, |
142 | | - children, |
143 | | - className, |
144 | | - ...props |
| 139 | + onCopy, |
| 140 | + onError, |
| 141 | + timeout = 2000, |
| 142 | + children, |
| 143 | + className, |
| 144 | + ...props |
145 | 145 | }: CodeBlockCopyButtonProps) => { |
146 | | - const [isCopied, setIsCopied] = useState(false); |
147 | | - const { code } = useContext(CodeBlockContext); |
148 | | - |
149 | | - const copyToClipboard = async () => { |
150 | | - if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { |
151 | | - onError?.(new Error("Clipboard API not available")); |
152 | | - return; |
153 | | - } |
154 | | - |
155 | | - try { |
156 | | - await navigator.clipboard.writeText(code); |
157 | | - setIsCopied(true); |
158 | | - onCopy?.(); |
159 | | - setTimeout(() => setIsCopied(false), timeout); |
160 | | - } catch (error) { |
161 | | - onError?.(error as Error); |
162 | | - } |
163 | | - }; |
164 | | - |
165 | | - const Icon = isCopied ? CheckIcon : CopyIcon; |
166 | | - |
167 | | - return ( |
168 | | - <Button |
169 | | - className={cn("shrink-0", className)} |
170 | | - onClick={copyToClipboard} |
171 | | - size="icon" |
172 | | - variant="ghost" |
173 | | - {...props} |
174 | | - > |
175 | | - {children ?? <Icon size={14} />} |
176 | | - </Button> |
177 | | - ); |
| 146 | + const [isCopied, setIsCopied] = useState(false); |
| 147 | + const { code } = useContext(CodeBlockContext); |
| 148 | + |
| 149 | + const copyToClipboard = async () => { |
| 150 | + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { |
| 151 | + onError?.(new Error("Clipboard API not available")); |
| 152 | + return; |
| 153 | + } |
| 154 | + |
| 155 | + try { |
| 156 | + await navigator.clipboard.writeText(code); |
| 157 | + setIsCopied(true); |
| 158 | + onCopy?.(); |
| 159 | + setTimeout(() => setIsCopied(false), timeout); |
| 160 | + } catch (error) { |
| 161 | + onError?.(error as Error); |
| 162 | + } |
| 163 | + }; |
| 164 | + |
| 165 | + const Icon = isCopied ? CheckIcon : CopyIcon; |
| 166 | + |
| 167 | + return ( |
| 168 | + <Button |
| 169 | + className={cn("shrink-0", className)} |
| 170 | + onClick={copyToClipboard} |
| 171 | + size="icon" |
| 172 | + variant="ghost" |
| 173 | + {...props} |
| 174 | + > |
| 175 | + {children ?? <Icon size={14} />} |
| 176 | + </Button> |
| 177 | + ); |
178 | 178 | }; |
0 commit comments