From 75fd2a5dcc810f794537cdbb4094d73d12b48b3e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 5 Dec 2025 18:03:23 +0800 Subject: [PATCH] feat(vscode-ide-companion/markdown): add copy button to code blocks in markdown renderer --- .../MarkdownRenderer/MarkdownRenderer.css | 41 +++++++++++ .../MarkdownRenderer/MarkdownRenderer.tsx | 70 +++++++++++++++---- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.css b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.css index 63d5e91d..159a3712 100644 --- a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.css +++ b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.css @@ -159,6 +159,47 @@ line-height: 1.5; } +.markdown-content .code-block-wrapper { + position: relative; +} + +.markdown-content .code-block-wrapper pre { + /* Reserve space so the copy button never overlaps code text */ + padding-top: 2rem; /* room for the button height */ + padding-right: 2.4rem; /* room for the button width */ +} + +.markdown-content .code-block-wrapper .copy-button { + position: absolute; + top: 6px; + right: 6px; + padding: 2px 8px; + font-size: 12px; + line-height: 1.6; + border-radius: 4px; + border: 1px solid var(--app-primary-border-color); + background-color: var(--app-elevated-background, rgba(255, 255, 255, 0.06)); + color: var(--app-secondary-foreground); + cursor: pointer; + z-index: 1; + opacity: 0; /* show on hover to reduce visual noise */ + transition: opacity 100ms ease-in-out; +} + +.markdown-content .code-block-wrapper:hover .copy-button, +.markdown-content .code-block-wrapper .copy-button:focus { + opacity: 1; +} + +.markdown-content .code-block-wrapper .copy-button:hover { + background-color: var(--app-list-hover-background, rgba(127, 127, 127, 0.1)); +} + +.markdown-content .code-block-wrapper .copy-button:disabled { + opacity: 0.7; + cursor: default; +} + .markdown-content pre code { background: none; border: none; diff --git a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.tsx b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.tsx index fda351d6..6e852e52 100644 --- a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.tsx +++ b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.tsx @@ -45,29 +45,43 @@ export const MarkdownRenderer: React.FC = ({ typographer: true, } as MarkdownItOptions); - // Add syntax highlighting for code blocks + // Add syntax highlighting + a copy button for code blocks md.use((md) => { + const renderWithCopy = (token: { + info: string; + content: string; + }): string => { + const lang = (token.info || 'plaintext').trim(); + const content = token.content; + + // Wrap in a container so we can position a copy button + return ( + `
` + + `` + + `
${md.utils.escapeHtml(content)}
` + + `
` + ); + }; + md.renderer.rules.code_block = function ( tokens, idx: number, _options, _env, ) { - const token = tokens[idx]; - const lang = token.info || 'plaintext'; - const content = token.content; - - // Add syntax highlighting classes - return `
${md.utils.escapeHtml(content)}
`; + const token = tokens[idx] as unknown as { + info: string; + content: string; + }; + return renderWithCopy(token); }; md.renderer.rules.fence = function (tokens, idx: number, _options, _env) { - const token = tokens[idx]; - const lang = token.info || 'plaintext'; - const content = token.content; - - // Add syntax highlighting classes - return `
${md.utils.escapeHtml(content)}
`; + const token = tokens[idx] as unknown as { + info: string; + content: string; + }; + return renderWithCopy(token); }; }); @@ -228,13 +242,41 @@ export const MarkdownRenderer: React.FC = ({ return container.innerHTML; }; - // Event delegation: intercept clicks on generated file-path links + // Event delegation: intercept clicks on copy buttons and generated file-path links const handleContainerClick = ( e: React.MouseEvent, ) => { const target = e.target as HTMLElement | null; if (!target) return; + // Handle copy button clicks for fenced code blocks + const copyBtn = (target.closest && + target.closest('button.copy-button')) as HTMLButtonElement | null; + if (copyBtn) { + e.preventDefault(); + e.stopPropagation(); + + try { + const wrapper = copyBtn.closest('.code-block-wrapper'); + const codeEl = wrapper?.querySelector('pre code'); + const text = codeEl?.textContent ?? ''; + if (text) { + void navigator.clipboard.writeText(text); + // Quick feedback + const original = copyBtn.textContent || 'Copy'; + copyBtn.textContent = 'Copied'; + copyBtn.disabled = true; + setTimeout(() => { + copyBtn.textContent = original; + copyBtn.disabled = false; + }, 1200); + } + } catch (err) { + console.warn('Copy failed:', err); + } + return; + } + // Find nearest anchor with our marker class const anchor = (target.closest && target.closest('a.file-path-link')) as HTMLAnchorElement | null;