| ... | ... | @@ -1,10 +1,13 @@ |
| 1 | 1 | import { getSharedHighlighter } from "@pierre/diffs"; |
| 2 | import { CheckIcon, CopyIcon } from "lucide-react"; |
| 2 | 3 | import { |
| 3 | 4 | Children, |
| 4 | 5 | isValidElement, |
| 6 | useCallback, |
| 5 | 7 | memo, |
| 6 | 8 | useEffect, |
| 7 | 9 | useMemo, |
| 10 | useRef, |
| 8 | 11 | useState, |
| 9 | 12 | type ReactNode, |
| 10 | 13 | } from "react"; |
| ... | ... | @@ -159,6 +162,54 @@ interface ShikiCodeBlockProps { |
| 159 | 162 | fallback: ReactNode; |
| 160 | 163 | } |
| 161 | 164 | |
| 165 | function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { |
| 166 | const [copied, setCopied] = useState(false); |
| 167 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 168 | const handleCopy = useCallback(() => { |
| 169 | if (typeof navigator === "undefined" || navigator.clipboard == null) { |
| 170 | return; |
| 171 | } |
| 172 | void navigator.clipboard |
| 173 | .writeText(code) |
| 174 | .then(() => { |
| 175 | if (copiedTimerRef.current != null) { |
| 176 | clearTimeout(copiedTimerRef.current); |
| 177 | } |
| 178 | setCopied(true); |
| 179 | copiedTimerRef.current = setTimeout(() => { |
| 180 | setCopied(false); |
| 181 | copiedTimerRef.current = null; |
| 182 | }, 1200); |
| 183 | }) |
| 184 | .catch(() => undefined); |
| 185 | }, [code]); |
| 186 | |
| 187 | useEffect( |
| 188 | () => () => { |
| 189 | if (copiedTimerRef.current != null) { |
| 190 | clearTimeout(copiedTimerRef.current); |
| 191 | copiedTimerRef.current = null; |
| 192 | } |
| 193 | }, |
| 194 | [], |
| 195 | ); |
| 196 | |
| 197 | return ( |
| 198 | <div className="chat-markdown-codeblock"> |
| 199 | <button |
| 200 | type="button" |
| 201 | className="chat-markdown-copy-button" |
| 202 | onClick={handleCopy} |
| 203 | title={copied ? "Copied" : "Copy code"} |
| 204 | aria-label={copied ? "Copied" : "Copy code"} |
| 205 | > |
| 206 | {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />} |
| 207 | </button> |
| 208 | {children} |
| 209 | </div> |
| 210 | ); |
| 211 | } |
| 212 | |
| 162 | 213 | function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { |
| 163 | 214 | const language = extractFenceLanguage(className); |
| 164 | 215 | const cacheKey = createHighlightCacheKey(code, language, themeName); |
| ... | ... | @@ -264,13 +315,15 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 264 | 315 | } |
| 265 | 316 | |
| 266 | 317 | return ( |
| 267 | | <ShikiCodeBlock |
| 268 | | className={codeBlock.className} |
| 269 | | code={codeBlock.code} |
| 270 | | themeName={diffThemeName} |
| 271 | | isStreaming={isStreaming} |
| 272 | | fallback={<pre {...props}>{children}</pre>} |
| 273 | | /> |
| 318 | <MarkdownCodeBlock code={codeBlock.code}> |
| 319 | <ShikiCodeBlock |
| 320 | className={codeBlock.className} |
| 321 | code={codeBlock.code} |
| 322 | themeName={diffThemeName} |
| 323 | isStreaming={isStreaming} |
| 324 | fallback={<pre {...props}>{children}</pre>} |
| 325 | /> |
| 326 | </MarkdownCodeBlock> |
| 274 | 327 | ); |
| 275 | 328 | }, |
| 276 | 329 | }), |