diff --git a/client/src/shared/ui/MarkdownContent.tsx b/client/src/shared/ui/MarkdownContent.tsx index 63a4159..d8061bf 100644 --- a/client/src/shared/ui/MarkdownContent.tsx +++ b/client/src/shared/ui/MarkdownContent.tsx @@ -1,6 +1,8 @@ -import { useEffect } from 'react'; -import { Box, Typography, useTheme } from '@mui/material'; +import { useEffect, useRef, useState, type HTMLAttributes } from 'react'; +import { Box, IconButton, Tooltip, Typography, useTheme } from '@mui/material'; import { alpha, getLuminance } from '@mui/material/styles'; +import CheckIcon from '@mui/icons-material/Check'; +import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined'; import ReactMarkdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; @@ -8,6 +10,81 @@ import hljsGithubLightUrl from 'highlight.js/styles/github.css?url'; import hljsGithubDarkUrl from 'highlight.js/styles/github-dark.css?url'; import AuthedImage from './AuthedImage'; +async function copyTextToClipboard(text: string): Promise { + if (!text) return false; + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to legacy path. The Clipboard API rejects in + // non-secure contexts (HTTP origins like Tailscale magic-DNS), + // and some browsers reject without a user gesture even on HTTPS. + } + } + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + ta.style.pointerEvents = 'none'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + +type PreProps = HTMLAttributes & { node?: unknown }; + +function CodeBlockWithCopy({ children, node: _node, ...preProps }: PreProps) { + const preRef = useRef(null); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const text = preRef.current?.textContent ?? ''; + const ok = await copyTextToClipboard(text); + if (!ok) return; + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }; + + return ( + + + + {copied ? ( + + ) : ( + + )} + + +
+        {children}
+      
+
+ ); +} + let hljsThemeLinkEl: HTMLLinkElement | null = null; function syncHljsStylesheet(isDarkUi: boolean) { @@ -35,6 +112,7 @@ function stripWrapperTags(text: string): string { } const markdownComponents: Partial = { + pre: (CodeBlockWithCopy as unknown) as Components['pre'], table({ children, ...props }) { return (