|
1 | | -import { memo, type ComponentProps } from "react"; |
| 1 | +import { memo, useLayoutEffect, useRef, type ComponentProps } from "react"; |
2 | 2 | import { createPortal } from "react-dom"; |
3 | 3 | import { cjk } from "@streamdown/cjk"; |
4 | 4 | import { code } from "@streamdown/code"; |
@@ -82,6 +82,87 @@ export const markdownReadOnlyComponents: Components = { |
82 | 82 | ...markdownComponents, |
83 | 83 | a: MarkdownReadOnlyLink, |
84 | 84 | }; |
| 85 | + |
| 86 | +const codeBlockSelector = '[data-streamdown="code-block"]'; |
| 87 | +const codeCopyButtonSelector = |
| 88 | + '[data-streamdown="code-block"] [data-streamdown="code-block-copy-button"]'; |
| 89 | +const codeBlockBodySelector = '[data-streamdown="code-block-body"] pre'; |
| 90 | + |
| 91 | +function enableCodeCopyButtons(root: HTMLElement) { |
| 92 | + root.querySelectorAll<HTMLButtonElement>(codeCopyButtonSelector).forEach((button) => { |
| 93 | + if (!button.disabled && !button.hasAttribute("disabled")) return; |
| 94 | + button.disabled = false; |
| 95 | + button.removeAttribute("disabled"); |
| 96 | + }); |
| 97 | +} |
| 98 | + |
| 99 | +function getCodeBlockText(button: HTMLButtonElement) { |
| 100 | + const codeBlock = button.closest(codeBlockSelector); |
| 101 | + const codeBody = codeBlock?.querySelector<HTMLElement>(codeBlockBodySelector); |
| 102 | + return codeBody?.textContent ?? null; |
| 103 | +} |
| 104 | + |
| 105 | +async function copyCodeBlockText(text: string) { |
| 106 | + try { |
| 107 | + await navigator.clipboard.writeText(text); |
| 108 | + } catch (error) { |
| 109 | + console.error("Failed to copy code block", error); |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +function useEnabledCodeCopyButtons(enabled: boolean) { |
| 114 | + const rootRef = useRef<HTMLDivElement | null>(null); |
| 115 | + |
| 116 | + useLayoutEffect(() => { |
| 117 | + if (!enabled) return; |
| 118 | + |
| 119 | + const root = rootRef.current; |
| 120 | + if (!root) return; |
| 121 | + |
| 122 | + // Streamdown disables copy controls while animating, but the copy handler |
| 123 | + // can safely copy the current partial code during streaming. |
| 124 | + enableCodeCopyButtons(root); |
| 125 | + |
| 126 | + const handleCopyClick = (event: MouseEvent) => { |
| 127 | + const target = event.target; |
| 128 | + if (!(target instanceof Element)) return; |
| 129 | + |
| 130 | + const button = target.closest(codeCopyButtonSelector); |
| 131 | + if (!(button instanceof HTMLButtonElement) || !root.contains(button)) return; |
| 132 | + |
| 133 | + const codeText = getCodeBlockText(button); |
| 134 | + if (codeText === null) return; |
| 135 | + |
| 136 | + event.preventDefault(); |
| 137 | + event.stopPropagation(); |
| 138 | + event.stopImmediatePropagation(); |
| 139 | + void copyCodeBlockText(codeText); |
| 140 | + }; |
| 141 | + |
| 142 | + root.addEventListener("click", handleCopyClick, true); |
| 143 | + |
| 144 | + let observer: MutationObserver | undefined; |
| 145 | + if (typeof MutationObserver !== "undefined") { |
| 146 | + observer = new MutationObserver(() => { |
| 147 | + enableCodeCopyButtons(root); |
| 148 | + }); |
| 149 | + observer.observe(root, { |
| 150 | + attributes: true, |
| 151 | + attributeFilter: ["disabled"], |
| 152 | + childList: true, |
| 153 | + subtree: true, |
| 154 | + }); |
| 155 | + } |
| 156 | + |
| 157 | + return () => { |
| 158 | + root.removeEventListener("click", handleCopyClick, true); |
| 159 | + observer?.disconnect(); |
| 160 | + }; |
| 161 | + }, [enabled]); |
| 162 | + |
| 163 | + return rootRef; |
| 164 | +} |
| 165 | + |
85 | 166 | const streamdownTranslations = { |
86 | 167 | close: "关闭", |
87 | 168 | copied: "已复制", |
@@ -215,46 +296,51 @@ export const Markdown = memo(function Markdown(props: MarkdownProps) { |
215 | 296 | showCaret = isAnimating, |
216 | 297 | readOnly = false, |
217 | 298 | } = props; |
| 299 | + const useStreamingMode = isAnimating; |
| 300 | + const isActivelyStreaming = showCaret; |
| 301 | + const codeCopyRootRef = useEnabledCodeCopyButtons(!readOnly && isActivelyStreaming); |
218 | 302 | // Keep Streamdown's caret pseudo-element mounted while in streaming mode; |
219 | 303 | // `showCaret` only toggles visibility so the final token does not reflow. |
220 | | - const keepCaretSlot = isAnimating; |
| 304 | + const keepCaretSlot = useStreamingMode; |
221 | 305 |
|
222 | 306 | return ( |
223 | | - <Streamdown |
224 | | - className={cn( |
225 | | - "chat-markdown max-w-none break-words", |
226 | | - isAnimating ? "chat-markdown--streaming" : "chat-markdown--static", |
227 | | - // Streamdown's memo equality does not include `caret` in its check, |
228 | | - // so toggling the caret prop alone does not invalidate the render. |
229 | | - // Mirror the visibility into a className modifier to force a re-render |
230 | | - // that recomputes the inline `--streamdown-caret` style. |
231 | | - showCaret ? "chat-markdown--caret-on" : "chat-markdown--caret-off", |
232 | | - className, |
233 | | - )} |
234 | | - plugins={streamdownPlugins} |
235 | | - remarkPlugins={remarkPlugins} |
236 | | - components={readOnly ? markdownReadOnlyComponents : markdownComponents} |
237 | | - mode={isAnimating ? "streaming" : "static"} |
238 | | - dir="auto" |
239 | | - parseIncompleteMarkdown |
240 | | - normalizeHtmlIndentation |
241 | | - isAnimating={isAnimating} |
242 | | - caret={keepCaretSlot ? "block" : undefined} |
243 | | - animated={false} |
244 | | - linkSafety={{ |
245 | | - enabled: !readOnly, |
246 | | - renderModal: (modalProps) => <ExternalLinkModal {...modalProps} />, |
247 | | - }} |
248 | | - {...(isAnimating ? {} : { shikiTheme: ["github-light", "github-dark"] as const })} |
249 | | - controls={{ |
250 | | - code: { copy: !readOnly, download: false }, |
251 | | - mermaid: { copy: !readOnly, download: false, fullscreen: !readOnly, panZoom: !readOnly }, |
252 | | - table: { copy: !readOnly, download: false, fullscreen: !readOnly }, |
253 | | - }} |
254 | | - translations={streamdownTranslations} |
255 | | - > |
256 | | - {content} |
257 | | - </Streamdown> |
| 307 | + <div ref={codeCopyRootRef} style={{ display: "contents" }}> |
| 308 | + <Streamdown |
| 309 | + className={cn( |
| 310 | + "chat-markdown max-w-none break-words", |
| 311 | + useStreamingMode ? "chat-markdown--streaming" : "chat-markdown--static", |
| 312 | + // Streamdown's memo equality does not include `caret` in its check, |
| 313 | + // so toggling the caret prop alone does not invalidate the render. |
| 314 | + // Mirror the visibility into a className modifier to force a re-render |
| 315 | + // that recomputes the inline `--streamdown-caret` style. |
| 316 | + showCaret ? "chat-markdown--caret-on" : "chat-markdown--caret-off", |
| 317 | + className, |
| 318 | + )} |
| 319 | + plugins={streamdownPlugins} |
| 320 | + remarkPlugins={remarkPlugins} |
| 321 | + components={readOnly ? markdownReadOnlyComponents : markdownComponents} |
| 322 | + mode={useStreamingMode ? "streaming" : "static"} |
| 323 | + dir="auto" |
| 324 | + parseIncompleteMarkdown |
| 325 | + normalizeHtmlIndentation |
| 326 | + isAnimating={isActivelyStreaming} |
| 327 | + caret={keepCaretSlot ? "block" : undefined} |
| 328 | + animated={false} |
| 329 | + linkSafety={{ |
| 330 | + enabled: !readOnly, |
| 331 | + renderModal: (modalProps) => <ExternalLinkModal {...modalProps} />, |
| 332 | + }} |
| 333 | + {...(useStreamingMode ? {} : { shikiTheme: ["github-light", "github-dark"] as const })} |
| 334 | + controls={{ |
| 335 | + code: { copy: !readOnly, download: false }, |
| 336 | + mermaid: { copy: !readOnly, download: false, fullscreen: !readOnly, panZoom: !readOnly }, |
| 337 | + table: { copy: !readOnly, download: false, fullscreen: !readOnly }, |
| 338 | + }} |
| 339 | + translations={streamdownTranslations} |
| 340 | + > |
| 341 | + {content} |
| 342 | + </Streamdown> |
| 343 | + </div> |
258 | 344 | ); |
259 | 345 | }); |
260 | 346 |
|
|
0 commit comments