Skip to content

Commit 741b1ed

Browse files
shouhanzen0xToshii
andauthored
Refactor copy buttons (RooCodeInc#3456)
* refactor: moved copy logic to CopyButtonComponents.tsx * refactor: simplified copy button components * clean: format & cleanup code * clean: removed comments * fix: fixed aria labels * clean: removed old comments * clean: reduced deltas * clean: deleted comment in ChatRow.tsx * updates * changeset --------- Co-authored-by: Toshii <[email protected]>
1 parent 8b4e8ce commit 741b1ed

File tree

4 files changed

+174
-98
lines changed

4 files changed

+174
-98
lines changed

.changeset/shiny-taxis-suffer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"claude-dev": minor
3+
---
4+
5+
update the copy button functionality

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 16 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
22
import deepEqual from "fast-deep-equal"
33
import React, { memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
4+
import styled from "styled-components"
5+
import { useEvent, useSize } from "react-use"
46

57
import CreditLimitError from "@/components/chat/CreditLimitError"
68
import { OptionsButtons } from "@/components/chat/OptionsButtons"
@@ -9,6 +11,8 @@ import { CheckmarkControl } from "@/components/common/CheckmarkControl"
911
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
1012
import MarkdownBlock from "@/components/common/MarkdownBlock"
1113
import SuccessButton from "@/components/common/SuccessButton"
14+
import { WithCopyButton } from "@/components/common/CopyButton"
15+
import Thumbnails from "@/components/common/Thumbnails"
1216
import McpResponseDisplay from "@/components/mcp/chat-display/McpResponseDisplay"
1317
import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server-row/McpResourceRow"
1418
import McpToolRow from "@/components/mcp/configuration/tabs/installed/server-row/McpToolRow"
@@ -28,76 +32,19 @@ import {
2832
} from "@shared/ExtensionMessage"
2933
import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
3034
import { Int64Request, StringRequest } from "@shared/proto/common"
31-
import { useEvent, useSize } from "react-use"
32-
import styled from "styled-components"
33-
import { CheckpointControls } from "../common/CheckpointControls"
35+
3436
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
37+
import { CheckpointControls } from "../common/CheckpointControls"
3538
import NewTaskPreview from "./NewTaskPreview"
36-
import QuoteButton from "./QuoteButton"
3739
import ReportBugPreview from "./ReportBugPreview"
3840
import UserMessage from "./UserMessage"
39-
40-
interface CopyButtonProps {
41-
textToCopy: string | undefined
42-
}
41+
import QuoteButton from "./QuoteButton"
4342

4443
const normalColor = "var(--vscode-foreground)"
4544
const errorColor = "var(--vscode-errorForeground)"
4645
const successColor = "var(--vscode-charts-green)"
4746
const cancelledColor = "var(--vscode-descriptionForeground)"
4847

49-
const CopyButtonStyled = styled(VSCodeButton)`
50-
position: absolute;
51-
bottom: 2px;
52-
right: 2px;
53-
z-index: 1;
54-
opacity: 0;
55-
`
56-
57-
interface WithCopyButtonProps {
58-
children: React.ReactNode
59-
textToCopy?: string
60-
style?: React.CSSProperties
61-
ref?: React.Ref<HTMLDivElement>
62-
onMouseUp?: (event: MouseEvent<HTMLDivElement>) => void
63-
}
64-
65-
const StyledContainer = styled.div`
66-
position: relative;
67-
68-
&:hover ${CopyButtonStyled} {
69-
opacity: 1;
70-
}
71-
`
72-
73-
const WithCopyButton = React.forwardRef<HTMLDivElement, WithCopyButtonProps>(
74-
({ children, textToCopy, style, onMouseUp, ...props }, ref) => {
75-
const [copied, setCopied] = useState(false)
76-
77-
const handleCopy = () => {
78-
if (!textToCopy) return
79-
80-
navigator.clipboard.writeText(textToCopy).then(() => {
81-
setCopied(true)
82-
setTimeout(() => {
83-
setCopied(false)
84-
}, 1500)
85-
})
86-
}
87-
88-
return (
89-
<StyledContainer ref={ref} onMouseUp={onMouseUp} style={style} {...props}>
90-
{children}
91-
{textToCopy && (
92-
<CopyButtonStyled appearance="icon" onClick={handleCopy} aria-label={copied ? "Copied" : "Copy"}>
93-
<span className={`codicon codicon-${copied ? "check" : "copy"}`}></span>
94-
</CopyButtonStyled>
95-
)}
96-
</StyledContainer>
97-
)
98-
},
99-
)
100-
10148
const ChatRowContainer = styled.div`
10249
padding: 10px 6px 10px 15px;
10350
position: relative;
@@ -1086,7 +1033,11 @@ export const ChatRowContent = ({
10861033
)
10871034
case "text":
10881035
return (
1089-
<WithCopyButton ref={contentRef} onMouseUp={handleMouseUp} textToCopy={message.text}>
1036+
<WithCopyButton
1037+
ref={contentRef}
1038+
onMouseUp={handleMouseUp}
1039+
textToCopy={message.text}
1040+
position="bottom-right">
10901041
<Markdown markdown={message.text} />
10911042
{quoteButtonState.visible && (
10921043
<QuoteButton
@@ -1323,6 +1274,7 @@ export const ChatRowContent = ({
13231274
ref={contentRef}
13241275
onMouseUp={handleMouseUp}
13251276
textToCopy={text}
1277+
position="bottom-right"
13261278
style={{
13271279
color: "var(--vscode-charts-green)",
13281280
paddingTop: 10,
@@ -1487,6 +1439,7 @@ export const ChatRowContent = ({
14871439
ref={contentRef}
14881440
onMouseUp={handleMouseUp}
14891441
textToCopy={text}
1442+
position="bottom-right"
14901443
style={{
14911444
color: "var(--vscode-charts-green)",
14921445
paddingTop: 10,
@@ -1557,6 +1510,7 @@ export const ChatRowContent = ({
15571510
ref={contentRef}
15581511
onMouseUp={handleMouseUp}
15591512
textToCopy={question}
1513+
position="bottom-right"
15601514
style={{ paddingTop: 10 }}>
15611515
<Markdown markdown={question} />
15621516
<OptionsButtons
@@ -1640,7 +1594,7 @@ export const ChatRowContent = ({
16401594
response = message.text
16411595
}
16421596
return (
1643-
<WithCopyButton ref={contentRef} onMouseUp={handleMouseUp} textToCopy={response}>
1597+
<WithCopyButton ref={contentRef} onMouseUp={handleMouseUp} textToCopy={response} position="bottom-right">
16441598
<Markdown markdown={response} />
16451599
<OptionsButtons
16461600
options={options}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React, { useState, useRef, forwardRef } from "react"
2+
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
3+
import styled from "styled-components"
4+
5+
// ======== Interfaces ========
6+
7+
interface CopyButtonProps {
8+
textToCopy?: string
9+
onCopy?: () => string | void | null
10+
className?: string
11+
ariaLabel?: string
12+
}
13+
14+
interface WithCopyButtonProps {
15+
children: React.ReactNode
16+
textToCopy?: string
17+
onCopy?: () => string | void | null
18+
position?: "top-right" | "bottom-right"
19+
style?: React.CSSProperties
20+
className?: string
21+
onMouseUp?: (event: React.MouseEvent<HTMLDivElement>) => void
22+
ariaLabel?: string
23+
}
24+
25+
// ======== Styled Components ========
26+
27+
const StyledButton = styled(VSCodeButton)`
28+
z-index: 1;
29+
`
30+
31+
// Unified container component
32+
const ContentContainer = styled.div`
33+
position: relative;
34+
`
35+
36+
// Unified button container with flexible positioning
37+
const ButtonContainer = styled.div<{ $position?: "top-right" | "bottom-right" }>`
38+
position: absolute;
39+
${(props) => {
40+
switch (props.$position) {
41+
case "bottom-right":
42+
return "bottom: 2px; right: 2px;"
43+
case "top-right":
44+
default:
45+
return "top: 5px; right: 5px;"
46+
}
47+
}}
48+
z-index: 1;
49+
opacity: 0;
50+
51+
${ContentContainer}:hover & {
52+
opacity: 1;
53+
}
54+
`
55+
56+
// ======== Component Implementations ========
57+
58+
/**
59+
* Base copy button component with clipboard functionality
60+
*/
61+
export const CopyButton: React.FC<CopyButtonProps> = ({ textToCopy, onCopy, className = "", ariaLabel }) => {
62+
const [copied, setCopied] = useState(false)
63+
64+
const handleCopy = () => {
65+
if (!textToCopy && !onCopy) return
66+
67+
let textToCopyFinal = textToCopy
68+
69+
if (onCopy) {
70+
const result = onCopy()
71+
if (typeof result === "string") {
72+
textToCopyFinal = result
73+
}
74+
}
75+
76+
if (textToCopyFinal) {
77+
navigator.clipboard
78+
.writeText(textToCopyFinal)
79+
.then(() => {
80+
setCopied(true)
81+
setTimeout(() => setCopied(false), 1500)
82+
})
83+
.catch((err) => console.error("Copy failed", err))
84+
}
85+
}
86+
87+
return (
88+
<StyledButton
89+
appearance="icon"
90+
onClick={handleCopy}
91+
className={className}
92+
aria-label={copied ? "Copied" : ariaLabel || "Copy"}>
93+
<span className={`codicon codicon-${copied ? "check" : "copy"}`}></span>
94+
</StyledButton>
95+
)
96+
}
97+
98+
/**
99+
* Container component that wraps content with a copy button
100+
*/
101+
export const WithCopyButton = forwardRef<HTMLDivElement, WithCopyButtonProps>(
102+
(
103+
{
104+
children,
105+
textToCopy,
106+
onCopy,
107+
position = "top-right",
108+
style,
109+
className,
110+
onMouseUp,
111+
ariaLabel, // Destructure ariaLabel
112+
...props
113+
},
114+
ref,
115+
) => {
116+
return (
117+
<ContentContainer ref={ref} onMouseUp={onMouseUp} style={style} className={className} {...props}>
118+
{children}
119+
{(textToCopy || onCopy) && (
120+
<ButtonContainer $position={position}>
121+
<CopyButton
122+
textToCopy={textToCopy}
123+
onCopy={onCopy}
124+
ariaLabel={ariaLabel} // Pass through the ariaLabel prop directly
125+
/>
126+
</ButtonContainer>
127+
)}
128+
</ContentContainer>
129+
)
130+
},
131+
)
132+
133+
// Default export for convenience if needed, though named exports are preferred for clarity
134+
const CopyButtonComponents = {
135+
CopyButton,
136+
WithCopyButton,
137+
}
138+
export default CopyButtonComponents

webview-ui/src/components/common/MarkdownBlock.tsx

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
2-
import MermaidBlock from "@/components/common/MermaidBlock"
3-
import { useExtensionState } from "@/context/ExtensionStateContext"
4-
import { StateServiceClient } from "@/services/grpc-client"
5-
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/state"
6-
import type { ComponentProps } from "react"
71
import React, { memo, useEffect, useRef, useState } from "react"
2+
import type { ComponentProps } from "react"
83
import { useRemark } from "react-remark"
94
import rehypeHighlight, { Options } from "rehype-highlight"
105
import rehypeKatex from "rehype-katex"
116
import remarkMath from "remark-math"
127
import styled from "styled-components"
13-
import type { Node } from "unist"
148
import { visit } from "unist-util-visit"
9+
import type { Node } from "unist"
10+
import { useExtensionState } from "@/context/ExtensionStateContext"
11+
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
12+
import MermaidBlock from "@/components/common/MermaidBlock"
13+
import { WithCopyButton } from "./CopyButton"
14+
import { StateServiceClient } from "@/services/grpc-client"
15+
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/state"
1516

1617
// Styled component for Act Mode text with more specific styling
1718
const ActModeHighlight: React.FC = () => (
@@ -178,24 +179,6 @@ const remarkPreventBoldFilenames = () => {
178179
}
179180
}
180181

181-
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
182-
183-
const CopyButton = styled(VSCodeButton)`
184-
position: absolute;
185-
top: 5px;
186-
right: 5px;
187-
z-index: 1;
188-
opacity: 0;
189-
`
190-
191-
const CodeBlockContainer = styled.div`
192-
position: relative;
193-
194-
&:hover ${CopyButton} {
195-
opacity: 1;
196-
}
197-
`
198-
199182
const StyledMarkdown = styled.div`
200183
pre {
201184
background-color: ${CODE_BLOCK_BG_COLOR};
@@ -337,30 +320,26 @@ const PreWithCopyButton = ({
337320
...preProps
338321
}: { theme: Record<string, string> } & React.HTMLAttributes<HTMLPreElement>) => {
339322
const preRef = useRef<HTMLPreElement>(null)
340-
const [copied, setCopied] = useState(false)
341323

342324
const handleCopy = () => {
343325
if (preRef.current) {
344326
const codeElement = preRef.current.querySelector("code")
345327
const textToCopy = codeElement ? codeElement.textContent : preRef.current.textContent
346328

347329
if (!textToCopy) return
348-
navigator.clipboard.writeText(textToCopy).then(() => {
349-
setCopied(true)
350-
setTimeout(() => setCopied(false), 1500)
351-
})
330+
return textToCopy
352331
}
332+
return null
353333
}
354334

335+
const styledPreProps = theme ? { ...preProps, theme } : preProps
336+
355337
return (
356-
<CodeBlockContainer>
357-
<CopyButton appearance="icon" onClick={handleCopy} aria-label={copied ? "Copied" : "Copy"}>
358-
<span className={`codicon codicon-${copied ? "check" : "copy"}`}></span>
359-
</CopyButton>
360-
<StyledPre {...preProps} theme={theme} ref={preRef}>
338+
<WithCopyButton onCopy={handleCopy} position="top-right" ariaLabel="Copy code">
339+
<StyledPre {...styledPreProps} ref={preRef}>
361340
{children}
362341
</StyledPre>
363-
</CodeBlockContainer>
342+
</WithCopyButton>
364343
)
365344
}
366345

0 commit comments

Comments
 (0)