Skip to content

Commit afe89ec

Browse files
committed
feat(editor): add hover-revealed copy button to fenced code blocks
Overlays a copy-to-clipboard button inside the code well's top-right corner, hidden until the block is hovered or the button is keyboard-focused. Copies via CodeNode.getTextContent() (not the DOM's own textContent, which would drop line breaks). Extracts the clipboard-write + success/failure toast shape into a shared useCopyToClipboard hook and switches every existing hand-rolled copy affordance (CodeBlock, TaskContextMenu, TaskDetailDialog, McpServerSection) over to it.
1 parent 83e2dea commit afe89ec

13 files changed

Lines changed: 486 additions & 56 deletions

File tree

src/components/molecules/CodeBlock.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Copy } from "lucide-react";
22
import { useCallback } from "react";
3-
import { toast } from "sonner";
3+
4+
import { useCopyToClipboard } from "@/hooks/ui/useCopyToClipboard";
45

56
import { IconButton } from "./IconButton";
67

@@ -17,17 +18,11 @@ export function CodeBlock({
1718
ariaLabel,
1819
className,
1920
}: CodeBlockProps) {
21+
const copyToClipboard = useCopyToClipboard();
2022
const handleCopy = useCallback(() => {
2123
if (!code) return;
22-
navigator.clipboard
23-
.writeText(code)
24-
.then(() => {
25-
toast.success(copyToast);
26-
})
27-
.catch(() => {
28-
toast.error("Failed to copy to clipboard");
29-
});
30-
}, [code, copyToast]);
24+
copyToClipboard(code, { success: copyToast, failure: "Failed to copy to clipboard" });
25+
}, [code, copyToast, copyToClipboard]);
3126

3227
return (
3328
<div className={`relative ${className ?? ""}`}>

src/components/molecules/MarkdownEditor/AGENTS.md

Lines changed: 3 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2+
3+
import { fencedCodeBlock, renderTestEditor } from "./__tests__/utils";
4+
import { CodeBlockCopyPlugin } from "./CodeBlockCopyPlugin";
5+
6+
describe("CodeBlockCopyPlugin", () => {
7+
let writeText: ReturnType<typeof vi.spyOn>;
8+
9+
beforeEach(() => {
10+
writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
11+
});
12+
13+
afterEach(() => {
14+
writeText.mockRestore();
15+
});
16+
17+
// The button sits INSIDE the code well itself (its `.cork-code-block-code-area`
18+
// container, alongside `<code>`) — not in a header row above it like the
19+
// language tab, per the task spec ("inside the dark background, not
20+
// outside it"). It must NOT be nested inside the tab (buttons can't nest)
21+
// and the wrapper's other direct child must still be the tab itself.
22+
test("renders a copy button inside the code well, alongside <code>", async () => {
23+
const { screen } = await renderTestEditor({
24+
initialValue: "```js\nconsole.log(1)\n```",
25+
plugins: <CodeBlockCopyPlugin />,
26+
});
27+
28+
const textbox = screen.getByRole("textbox");
29+
const wrapper = textbox.element().querySelector(".cork-code-block-wrapper");
30+
expect(wrapper).not.toBeNull();
31+
32+
const codeArea = wrapper?.querySelector(".cork-code-block-code-area");
33+
expect(codeArea).not.toBeNull();
34+
35+
const button = codeArea?.querySelector("button.cork-code-block-copy");
36+
expect(button).not.toBeNull();
37+
expect(button?.getAttribute("aria-label")).toBe("Copy code block");
38+
expect(codeArea?.querySelector("code")).not.toBeNull();
39+
40+
// The tab is a separate direct child of the wrapper, not nested with the
41+
// copy button inside the code area.
42+
expect(wrapper?.querySelector(":scope > button.cork-code-block-tab")).not.toBeNull();
43+
expect(codeArea?.querySelector("button.cork-code-block-tab")).toBeNull();
44+
});
45+
46+
// The regression this plugin exists to avoid: reading `Element.textContent`
47+
// instead of `codeNode.getTextContent()` would silently squash every line
48+
// together, since `<br>` (LineBreakNode's rendered element) contributes no
49+
// "\n" to `textContent`. A multi-line block must copy with real newlines.
50+
test("clicking the button copies the block's exact multi-line source", async () => {
51+
const source = fencedCodeBlock("js", 3);
52+
const expectedCode = source.split("\n").slice(1, -1).join("\n");
53+
54+
const { screen, user } = await renderTestEditor({
55+
initialValue: source,
56+
plugins: <CodeBlockCopyPlugin />,
57+
});
58+
59+
await user.click(screen.getByRole("button", { name: "Copy code block" }));
60+
61+
expect(writeText).toHaveBeenCalledExactlyOnceWith(expectedCode);
62+
});
63+
64+
// A doc with more than one code block: clicking one block's button must
65+
// never copy a different block's text — confirms the click delegation
66+
// resolves the CLICKED button's own nearest CodeNode, not e.g. the first
67+
// code block in the document.
68+
test("clicking a specific block's button copies only that block's text", async () => {
69+
const { screen, user } = await renderTestEditor({
70+
initialValue: "```js\nfirst\n```\n\n```py\nsecond\n```",
71+
plugins: <CodeBlockCopyPlugin />,
72+
});
73+
74+
const buttons = screen.getByRole("button", { name: "Copy code block" });
75+
await user.click(buttons.nth(1));
76+
77+
expect(writeText).toHaveBeenCalledExactlyOnceWith("second");
78+
});
79+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { $isCodeNode } from "@lexical/code";
2+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3+
import { $getNearestNodeFromDOMNode, isDOMNode } from "lexical";
4+
import { useEffect, useEffectEvent } from "react";
5+
6+
import { useCopyToClipboard } from "@/hooks/ui/useCopyToClipboard";
7+
8+
import { COPY_BUTTON_CLASS } from "./CorkCodeNode";
9+
10+
// Click-triggered copy-to-clipboard for the button `CorkCodeNode` overlays
11+
// inside the dark code well's top-right corner (see `.cork-code-block-code-area`
12+
// in `CorkCodeNode.ts`'s header comment). Delegated on the editor root by
13+
// class name — mirroring `FloatingCodeLanguageEditorPlugin`'s language tab —
14+
// rather than a listener attached inside `CorkCodeNode.createDOM`: that file
15+
// only describes DOM *shape*, plugins own *behavior* (see its header
16+
// comment).
17+
//
18+
// `codeNode.getTextContent()` (NOT the DOM's `Element.textContent`) is the
19+
// only correct source for the copied string. A code block's line breaks are
20+
// represented as `LineBreakNode` children (or, pre-highlight, literal "\n"
21+
// inside a single TextNode) — never as literal "\n" characters next to a
22+
// `<br>` — and `<br>` elements contribute nothing to `Element.textContent`.
23+
// Reading the live DOM directly would silently concatenate every line into
24+
// one, losing every line break. `getTextContent()` normalizes all of that to
25+
// real "\n" characters (see `CodeBlockEscapePlugin.ts`'s own comment on the
26+
// exact same fact, verified against `@lexical/code-core`'s own test suite),
27+
// so it always returns the exact source text, however the lines happen to
28+
// be represented at the moment of the click.
29+
export function CodeBlockCopyPlugin(): null {
30+
const [editor] = useLexicalComposerContext();
31+
const copyToClipboard = useCopyToClipboard();
32+
33+
// `preventDefault` on mousedown so clicking the button doesn't shift native
34+
// focus/selection into the editor first — same reason the tab's own click
35+
// handling does this in `FloatingCodeLanguageEditorPlugin`.
36+
const onMouseDown = useEffectEvent((e: MouseEvent) => {
37+
const target = e.target;
38+
if (!isDOMNode(target) || !(target instanceof Element)) return;
39+
if (target.closest(`.${COPY_BUTTON_CLASS}`) != null) {
40+
e.preventDefault();
41+
}
42+
});
43+
44+
const onClick = useEffectEvent((e: MouseEvent) => {
45+
const target = e.target;
46+
if (!isDOMNode(target) || !(target instanceof Element)) return;
47+
const button = target.closest(`.${COPY_BUTTON_CLASS}`);
48+
if (button == null) return;
49+
e.preventDefault();
50+
51+
const text = editor.read(() => {
52+
const node = $getNearestNodeFromDOMNode(button);
53+
return $isCodeNode(node) ? node.getTextContent() : null;
54+
});
55+
if (text == null) return;
56+
57+
copyToClipboard(text, {
58+
success: "Copied code block to clipboard",
59+
failure: "Failed to copy code block to clipboard",
60+
});
61+
});
62+
63+
useEffect(() => {
64+
return editor.registerRootListener((rootElement) => {
65+
if (rootElement != null) {
66+
const down = (e: MouseEvent) => onMouseDown(e);
67+
const click = (e: MouseEvent) => onClick(e);
68+
rootElement.addEventListener("mousedown", down);
69+
rootElement.addEventListener("click", click);
70+
return () => {
71+
rootElement.removeEventListener("mousedown", down);
72+
rootElement.removeEventListener("click", click);
73+
};
74+
}
75+
});
76+
}, [editor]);
77+
78+
return null;
79+
}

src/components/molecules/MarkdownEditor/CorkCodeNode.spec.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { $createCodeNode } from "@lexical/code";
2+
import { Copy } from "lucide-react";
23
import { describe, expect, test } from "vitest";
4+
import { render } from "vitest-browser-react";
35

46
import { createTestHeadlessEditor, renderTestEditor } from "./__tests__/utils";
5-
import { CorkCodeNode } from "./CorkCodeNode";
7+
import { $createCopyIcon, CorkCodeNode } from "./CorkCodeNode";
68

79
describe("CorkCodeNode (language chip)", () => {
810
// ```js fence → wrapper <div> contains a <button> tab (the tab IS the
@@ -204,3 +206,47 @@ describe("CorkCodeNode (HTML export)", () => {
204206
);
205207
});
206208
});
209+
210+
// `$createCopyIcon` hand-transcribes `lucide-react`'s `Copy` icon into raw DOM
211+
// (createDOM runs outside React, so the real component isn't directly usable
212+
// there) — this pins that transcription against the ACTUAL rendered output of
213+
// the real `lucide-react` `Copy` component, so a future `lucide-react` upgrade
214+
// that redraws the icon (changes the rect/path data) fails this test instead
215+
// of silently drifting the button's shape out of sync with every other
216+
// lucide-react icon in the app.
217+
describe("CorkCodeNode (copy icon fidelity)", () => {
218+
test("$createCopyIcon's markup matches lucide-react's real Copy icon", async () => {
219+
await render(<Copy />);
220+
// `render` mounts into `document.body` (cleaned up between tests by
221+
// `vitest-browser-react`'s own afterEach) — no other test in this file
222+
// renders a bare `lucide-react` icon directly, so this is unambiguous.
223+
const reference = document.querySelector("svg.lucide-copy");
224+
if (!(reference instanceof SVGSVGElement)) {
225+
throw new Error("expected lucide-react's Copy icon to render an <svg>");
226+
}
227+
228+
const built = $createCopyIcon();
229+
230+
expect(built.getAttribute("viewBox")).toBe(reference.getAttribute("viewBox"));
231+
232+
const referenceChildren = Array.from(reference.children);
233+
const builtChildren = Array.from(built.children);
234+
expect(builtChildren).toHaveLength(referenceChildren.length);
235+
236+
// Only the geometry-defining attributes are compared — `class`,
237+
// `aria-hidden`, and sizing (`width`/`height`, controlled by this app's
238+
// own CSS rather than SVG attributes) are deliberate, cosmetic
239+
// differences from the raw component output, not drift risks. `d` (path)
240+
// and `width`/`height`/`x`/`y`/`rx`/`ry` (rect) are exactly what a
241+
// lucide-react redraw would change.
242+
const GEOMETRY_ATTRS = ["d", "x", "y", "width", "height", "rx", "ry"];
243+
for (const [i, referenceChild] of referenceChildren.entries()) {
244+
const builtChild = builtChildren[i];
245+
expect(builtChild.tagName).toBe(referenceChild.tagName);
246+
for (const attr of GEOMETRY_ATTRS) {
247+
if (!referenceChild.hasAttribute(attr)) continue;
248+
expect(builtChild.getAttribute(attr)).toBe(referenceChild.getAttribute(attr));
249+
}
250+
}
251+
});
252+
});

0 commit comments

Comments
 (0)