mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): Add copy button to code blocks (#13458)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper";
|
||||
|
||||
describe("CopyableContentWrapper", () => {
|
||||
it("should hide the copy button by default", () => {
|
||||
render(
|
||||
<CopyableContentWrapper text="hello">
|
||||
<p>content</p>
|
||||
</CopyableContentWrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("should show the copy button on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CopyableContentWrapper text="hello">
|
||||
<p>content</p>
|
||||
</CopyableContentWrapper>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText("content"));
|
||||
|
||||
expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
|
||||
});
|
||||
|
||||
it("should copy text to clipboard on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CopyableContentWrapper text="copy me">
|
||||
<p>content</p>
|
||||
</CopyableContentWrapper>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("copy-to-clipboard"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(navigator.clipboard.readText()).resolves.toBe("copy me"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show copied state after clicking", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CopyableContentWrapper text="hello">
|
||||
<p>content</p>
|
||||
</CopyableContentWrapper>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("copy-to-clipboard"));
|
||||
|
||||
expect(screen.getByTestId("copy-to-clipboard")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"BUTTON$COPIED",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { code as Code } from "#/components/features/markdown/code";
|
||||
|
||||
describe("code (markdown)", () => {
|
||||
it("should render inline code without a copy button", () => {
|
||||
render(<Code>inline snippet</Code>);
|
||||
|
||||
expect(screen.getByText("inline snippet")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("copy-to-clipboard")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a multiline code block with a copy button", () => {
|
||||
render(<Code>{"line1\nline2"}</Code>);
|
||||
|
||||
expect(screen.getByText("line1 line2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a syntax-highlighted block with a copy button", () => {
|
||||
render(<Code className="language-js">{"console.log('hi')"}</Code>);
|
||||
|
||||
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should copy code block content to clipboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Code>{"line1\nline2"}</Code>);
|
||||
|
||||
await user.click(screen.getByTestId("copy-to-clipboard"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(navigator.clipboard.readText()).resolves.toBe("line1\nline2"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { ExtraProps } from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper";
|
||||
|
||||
// See https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight
|
||||
|
||||
@@ -15,6 +16,7 @@ export function code({
|
||||
React.HTMLAttributes<HTMLElement> &
|
||||
ExtraProps) {
|
||||
const match = /language-(\w+)/.exec(className || ""); // get the language
|
||||
const codeString = String(children).replace(/\n$/, "");
|
||||
|
||||
if (!match) {
|
||||
const isMultiline = String(children).includes("\n");
|
||||
@@ -37,29 +39,33 @@ export function code({
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: "#2a3038",
|
||||
padding: "1em",
|
||||
borderRadius: "4px",
|
||||
color: "#e6edf3",
|
||||
border: "1px solid #30363d",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code className={className}>{String(children).replace(/\n$/, "")}</code>
|
||||
</pre>
|
||||
<CopyableContentWrapper text={codeString}>
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: "#2a3038",
|
||||
padding: "1em",
|
||||
borderRadius: "4px",
|
||||
color: "#e6edf3",
|
||||
border: "1px solid #30363d",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code className={className}>{codeString}</code>
|
||||
</pre>
|
||||
</CopyableContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
className="rounded-lg"
|
||||
style={vscDarkPlus}
|
||||
language={match?.[1]}
|
||||
PreTag="div"
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
<CopyableContentWrapper text={codeString}>
|
||||
<SyntaxHighlighter
|
||||
className="rounded-lg"
|
||||
style={vscDarkPlus}
|
||||
language={match?.[1]}
|
||||
PreTag="div"
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
</CopyableContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { CopyToClipboardButton } from "./copy-to-clipboard-button";
|
||||
|
||||
export function CopyableContentWrapper({
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setIsCopied(true);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
if (isCopied) {
|
||||
timeout = setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isCopied]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopied}
|
||||
onClick={handleCopy}
|
||||
mode={isCopied ? "copied" : "copy"}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user