Skip to content

Commit b11527f

Browse files
committed
feat: add copy to clipboard functionality for code blocks in ArticleContent component
1 parent 1b624df commit b11527f

2 files changed

Lines changed: 91 additions & 21 deletions

File tree

components/ArticleContent.tsx

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { useEffect, useRef, useState } from "react";
44
import { marked } from "marked";
55
import DOMPurify from "dompurify";
66

@@ -10,6 +10,29 @@ interface ArticleContentProps {
1010

1111
const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
1212
const [sanitizedContent, setSanitizedContent] = useState<string>("");
13+
const containerRef = useRef<HTMLDivElement | null>(null);
14+
15+
async function copyTextToClipboard(text: string) {
16+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
17+
await navigator.clipboard.writeText(text);
18+
return;
19+
}
20+
21+
const textarea = document.createElement("textarea");
22+
textarea.value = text;
23+
textarea.setAttribute("readonly", "");
24+
textarea.style.position = "fixed";
25+
textarea.style.top = "-9999px";
26+
textarea.style.left = "-9999px";
27+
document.body.appendChild(textarea);
28+
textarea.select();
29+
const ok = document.execCommand("copy");
30+
document.body.removeChild(textarea);
31+
32+
if (!ok) {
33+
throw new Error("Copy failed");
34+
}
35+
}
1336

1437
useEffect(() => {
1538
async function sanitizeContent() {
@@ -27,9 +50,74 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
2750
sanitizeContent();
2851
}, [content]);
2952

53+
useEffect(() => {
54+
const root = containerRef.current;
55+
if (!root) return;
56+
57+
const preBlocks = Array.from(root.querySelectorAll("pre"));
58+
59+
const copySvg =
60+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
61+
const checkSvg =
62+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>';
63+
64+
for (const pre of preBlocks) {
65+
const existingWrapper = pre.closest('div[data-copy-wrapper="true"]');
66+
if (existingWrapper) continue;
67+
68+
const codeText = pre.querySelector("code")?.textContent ?? pre.textContent ?? "";
69+
if (!codeText.trim()) continue;
70+
71+
const wrapper = document.createElement("div");
72+
wrapper.dataset.copyWrapper = "true";
73+
wrapper.className = "relative";
74+
75+
const parent = pre.parentNode;
76+
if (!parent) continue;
77+
78+
parent.insertBefore(wrapper, pre);
79+
wrapper.appendChild(pre);
80+
81+
pre.style.paddingTop = "52px";
82+
pre.style.paddingRight = "52px";
83+
84+
const button = document.createElement("button");
85+
button.type = "button";
86+
button.className =
87+
"absolute top-3 right-3 h-8 w-8 flex items-center justify-center bg-[#121212] text-foreground transition-all duration-200 ease-out transform hover:scale-105 active:scale-95 hover:bg-[#8a2be2] hover:border-[#8a2be2] hover:text-white";
88+
button.innerHTML = copySvg;
89+
button.setAttribute("aria-label", "Copy code to clipboard");
90+
button.setAttribute("title", "Copy");
91+
92+
let resetTimer: number | undefined;
93+
94+
const handleClick = async () => {
95+
const textToCopy = pre.querySelector("code")?.textContent ?? pre.textContent ?? "";
96+
if (!textToCopy.trim()) return;
97+
98+
try {
99+
await copyTextToClipboard(textToCopy);
100+
button.innerHTML = checkSvg;
101+
button.setAttribute("title", "Copied");
102+
} catch {
103+
button.setAttribute("title", "Copy failed");
104+
}
105+
106+
if (resetTimer) window.clearTimeout(resetTimer);
107+
resetTimer = window.setTimeout(() => {
108+
button.innerHTML = copySvg;
109+
button.setAttribute("title", "Copy");
110+
}, 1500);
111+
};
112+
113+
button.addEventListener("click", handleClick);
114+
wrapper.appendChild(button);
115+
}
116+
}, [sanitizedContent]);
117+
30118
return (
31119
<article className="flex flex-col md:flex-row gap-6 md:gap-16 max-w-full lg:max-w-[70%] w-full mx-auto mt-6 md:mt-24 mb-10 px-4">
32-
<div className="markdown-body w-full">
120+
<div className="markdown-body w-full" ref={containerRef}>
33121
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
34122
</div>
35123
</article>

package-lock.json

Lines changed: 1 addition & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)