Markdown記事のコードブロックにコピーボタンを付ける【rehype-pretty-codeの生HTMLにDOM操作で後付け】
- この記事で分かること
- rehype-pretty-codeが出力するコードブロックの構造にあわせてCSSで見た目を整え、クライアントコンポーネントがマウント後にコピーボタンをDOM操作で差し込む実装
- 実際に使った環境
- Next.js 16.3.1(App Router)/ rehype-pretty-code(テーマはone-dark-pro)/ Tailwind CSS v4。このブログの記事ページとカテゴリーページで実際に動いている実装です
- 筆者の結論
- Markdown由来の生HTMLにはonClickを渡せない、という一点が実装の形を決めた。ボタン自体は70行ほどで、二重追加の防止とタイマーの後片付けが肝
このブログの記事には、コードブロックがたびたび出てきます。読者がそのコードを試すときに、範囲選択してコピーするのは意外と面倒です。そこで、コードブロックの右上にコピーボタンを付けています。
この記事では、Markdownのコードブロックがどんな構造のHTMLになるのか、その見た目をどうCSSで整えているのか、そしてコピーボタンをどう後付けしているのかを、実際のコードで紹介します。
🛠 実装してみて分かったこと
コピー処理そのものは、クリップボードに文字列を書き込む1行です。実装の形を決めたのは、その手前でした。このブログの記事本文はMarkdownをHTML文字列に変換して出力しているので、コードブロックはReactの管理の外にあります。つまり、普通に書くようにボタンのコンポーネントを置いてonClickを渡す、という方法が取れませんでした。
コードブロックはどんなHTMLになるか
このブログでは、Markdownのコードブロック(```で囲んだ部分)をrehype-pretty-codeで変換しています。シンタックスハイライトにはshikiが使われ、テーマはone-dark-proです。変換パイプラインの該当部分は次のとおりです。
.use(rehypePrettyCode, {
theme: "one-dark-pro",
keepBackground: true,
})出力されるHTMLは、おおよそ次の構造になります。
<figure data-rehype-pretty-code-figure>
<pre data-language="tsx" style="background-color: ...">
<code>...(色付けされた行)...</code>
</pre>
</figure>figureにdata-rehype-pretty-code-figureという属性が付き、preにはdata-languageとして言語名が入ります。以降のCSSとコピーボタンは、この2つの属性を目印にしています。
見た目はCSSだけで整える
エディタのウィンドウのような見た目は、すべてglobals.cssのCSSで作っています。HTMLに要素を足しているわけではありません。
.prose figure[data-rehype-pretty-code-figure] {
position: relative;
margin: 1.75rem 0;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid color-mix(in srgb, black 30%, transparent);
box-shadow: 0 12px 28px -16px rgba(0, 0, 0, 0.45);
background: #282c34;
}タイトルバーは疑似要素で描く
上部の細いバーと、左端の3つの丸い点は、::before疑似要素の背景にradial-gradientを3つ重ねて描いています。
.prose figure[data-rehype-pretty-code-figure]::before {
content: "";
display: block;
height: 2.25rem;
background-color: #21252b;
background-image: radial-gradient(
circle 5px at 18px 50%,
var(--accent-mauve) 99%,
transparent
),
radial-gradient(circle 5px at 36px 50%, var(--accent-olive) 99%, transparent),
radial-gradient(circle 5px at 54px 50%, var(--accent-blue) 99%, transparent);
background-repeat: no-repeat;
}点の色は、サイトのデザイントークン(--accent-mauveなど)から取っています。画像を使っていないので、ダークモードとライトモードのどちらでも崩れません。
言語ラベルは属性値をそのまま表示する
右上の言語名は、pre::afterのcontentにattr(data-language)を指定して出しています。HTMLに属性として入っている値を、CSSがそのまま文字として描画する機能です。
.prose figure[data-rehype-pretty-code-figure] pre::after {
content: attr(data-language);
position: absolute;
top: 0;
/* コピーボタン(.code-copy-button)分のスペースを右側に空けておく */
right: 3.1rem;
height: 2.25rem;
display: flex;
align-items: center;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}言語ラベルのrightを3.1remにしているのは、その右隣にコピーボタンの場所を空けるためです。
コピーボタンを後付けするクライアントコンポーネント
ここからが本題です。ボタンを置くCodeCopyButtonsは、画面には何も描画しないクライアントコンポーネント(return null)です。記事ページとカテゴリーページの本文の直後に1つ置いてあります。
"use client";
import { useEffect } from "react";
export default function CodeCopyButtons() {
useEffect(() => {
const figures = document.querySelectorAll<HTMLElement>(
".prose figure[data-rehype-pretty-code-figure]"
);
const cleanups: Array<() => void> = [];
figures.forEach((figure) => {
// 開発中のHMR再実行や、StrictModeの二重実行で二重追加されるのを防ぐ
if (figure.querySelector(".code-copy-button")) return;
const pre = figure.querySelector("pre");
if (!pre) return;
const button = document.createElement("button");
button.type = "button";
button.className = "code-copy-button";
button.setAttribute("aria-label", "コードをコピー");
button.innerHTML = COPY_ICON;
// ...クリック処理(後述)...
figure.appendChild(button);
});
return () => {
cleanups.forEach((fn) => fn());
};
}, []);
return null;
}useEffectはブラウザでの描画が終わった後に動くので、その時点で本文のHTMLはすでにDOMに入っています。そこからfigureを探して、<button>を作って末尾に追加しています。
二重追加を防ぐ
最初のif (figure.querySelector(".code-copy-button")) return;は、すでにボタンがあるfigureを飛ばすための判定です。開発中はホットリロードやReact StrictModeの影響で、useEffectが2回実行されることがあります。判定が無いと、ボタンが2つ並んでしまいます。
クリックしたときの処理
const handleClick = () => {
const code = pre.textContent ?? "";
navigator.clipboard
.writeText(code)
.then(() => {
button.innerHTML = CHECK_ICON;
button.classList.add("is-copied");
button.setAttribute("aria-label", "コピーしました");
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
button.innerHTML = COPY_ICON;
button.classList.remove("is-copied");
button.setAttribute("aria-label", "コードをコピー");
}, 1500);
})
.catch(() => {
// クリップボードAPIが使えない環境(非HTTPS等)では何もしない
});
};pre.textContentをコピーする: shikiは色付けのために、1文字ずつspanで囲みます。textContentはタグを除いた文字列だけを返すので、コードそのものがコピーされます- 成功したらアイコンを切り替える: コピーアイコンをチェックマークに変え、1.5秒後に元に戻します。色もサイトのアクセントカラー(オリーブグリーン)になり、
aria-labelも「コピーしました」に変わるので、スクリーンリーダーにも結果が伝わります - 連続でクリックされたとき:
clearTimeout(resetTimer)で前のタイマーを止めてから、新しいタイマーを仕掛けています。止めないと、2回目のクリックの1.5秒が経つ前に、1回目のタイマーがアイコンを元に戻してしまいます - 失敗しても何もしない: Clipboard APIは、HTTPSでないページなどでは使えません。その場合は例外を握りつぶして、画面は変えません
後片付け
useEffectの戻り値で、タイマーとイベントリスナーを解除しています。
button.addEventListener("click", handleClick);
figure.appendChild(button);
cleanups.push(() => {
clearTimeout(resetTimer);
button.removeEventListener("click", handleClick);
});ページ遷移でコンポーネントが外れるときに、残っているタイマーが消えたボタンを触りに行かないようにするためです。
ボタンの見た目
ボタンのCSSは、右上に絶対配置するだけの小さなものです。
.code-copy-button {
position: absolute;
top: 0;
right: 0.6rem;
height: 2.25rem;
width: 2rem;
display: flex;
align-items: center;
justify-content: center;
color: #9199a8;
background: transparent;
border: none;
border-radius: 0.4rem;
cursor: pointer;
}
.code-copy-button.is-copied {
color: var(--accent-olive);
}figureがposition: relativeなので、top: 0はタイトルバーの高さ(2.25rem)にそろいます。ボタンの高さもタイトルバーと同じ2.25remにしているため、バーの中に収まって見えます。
よくある質問
Q. コピーボタンをReactコンポーネントとして書くことはできませんか?
A. このブログのようにMarkdownをHTML文字列に変換してdangerouslySetInnerHTMLで出力している場合、コードブロックはReactツリーの外側にある生HTMLです。そこにJSXのonClickは渡せないため、マウント後にDOMを探して<button>を差し込み、イベントを手動で登録する方式にしています。MDXのようにコードブロックをReactコンポーネントに差し替えられる構成なら、コンポーネントとして書くこともできます。
Q. navigator.clipboardが使えない環境ではどうなりますか?
A. コピーに失敗してもエラーは出さず、何も起きないようにしています。非HTTPSのページなどではClipboard APIが使えないためです。本番のブログはHTTPSなので、通常の閲覧では問題になりません。
Q. ハイライトされたコードをコピーすると、色付けのためのタグも一緒にコピーされませんか?
A. コピーしているのはpre要素のtextContentなので、色付けのためのspanタグは含まれず、コードの文字列だけが入ります。
まとめ
コードブロックの見た目は、rehype-pretty-codeが出力するfigureとdata-language属性を目印に、CSSだけで整えました。コピーボタンは、Markdown由来の生HTMLにはReactのonClickを付けられないため、クライアントコンポーネントがマウント後にDOMへ差し込む形にしています。
小さな機能ですが、「二重追加を防ぐ」「連続クリックでタイマーが競合しないようにする」「アンマウント時に後片付けをする」という、DOMを直接触る実装で必要になる配慮が一通り出てきました。本文の生HTMLに含まれる見出しをブラウザ側で探して動きを足すという点では、追従目次の実装も同じ構成です。