AIツクリラボ
・Next.js制作Tips・10分で読めます・🔵 実装記録

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に含まれる見出しをブラウザ側で探して動きを足すという点では、追従目次の実装も同じ構成です。

この記事を書いた人

ラボ管理人

AIツール・ノーコード・Next.jsで実際に手を動かしながら、AIツクリラボを運営しています。

運営者情報を見る →